NIP-49: 私鑰加密
使用密碼保護 Nostr 私鑰的標準加密格式
概述
NIP-49 定義了一種使用密碼加密 secp256k1 私鑰的標準方法。
透過結合密碼派生密鑰(scrypt)和認證加密(XChaCha20-Poly1305),
用戶可以安全地儲存和傳輸加密後的私鑰,以 ncryptsec 格式編碼。
ncryptsec 格式
加密後的私鑰使用 bech32 編碼,前綴為 ncryptsec:
ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p 二進制結構(91 位元組)
| 欄位 | 大小 | 說明 |
|---|---|---|
| 版本號 | 1 byte | 固定為 0x02 |
| LOG_N | 1 byte | scrypt 的記憶體參數(16-22) |
| Salt | 16 bytes | 隨機鹽值 |
| Nonce | 24 bytes | XChaCha20 的隨機 nonce |
| 關聯資料 | 1 byte | 密鑰安全狀態標記 |
| 密文 | 48 bytes | 加密後的私鑰(32)+ 認證標籤(16) |
加密流程
1. 密碼正規化
密碼必須正規化為 Unicode NFKC 格式,確保在不同電腦和客戶端上輸入一致:
const normalizedPassword = password.normalize('NFKC'); 2. 密鑰派生(scrypt)
| 參數 | 值 | 說明 |
|---|---|---|
| Salt | 16 隨機位元組 | 每次加密隨機生成 |
| N | 2^LOG_N | 記憶體/時間權衡 |
| r | 8 | 區塊大小參數 |
| p | 1 | 並行參數 |
| 輸出 | 32 bytes | 對稱加密密鑰 |
LOG_N 參數對照表
| LOG_N | 記憶體需求 | 建議用途 |
|---|---|---|
| 16 | 64 MiB | 低端設備 |
| 17 | 128 MiB | 行動裝置 |
| 18 | 256 MiB | 一般電腦 |
| 19 | 512 MiB | 建議預設值 |
| 20 | 1 GiB | 高安全性 |
| 21 | 2 GiB | 極高安全性 |
| 22 | 4 GiB | 最高安全性 |
3. 對稱加密(XChaCha20-Poly1305)
- Nonce:24 隨機位元組
- 關聯資料:1 位元組的密鑰安全狀態
- 明文:32 位元組的原始私鑰
關聯資料值
| 值 | 意義 |
|---|---|
0x00 | 未知密鑰安全狀態 |
0x01 | 密鑰已知可能洩露 |
0x02 | 密鑰未被洩露(安全) |
TypeScript 實作
安裝依賴
npm install @noble/hashes @noble/ciphers nostr-tools 加密私鑰
import { scrypt } from '@noble/hashes/scrypt';
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { randomBytes } from '@noble/hashes/utils';
import { bech32 } from '@scure/base';
interface EncryptOptions {
logN?: number; // 16-22,預設 19
keySecurity?: 0x00 | 0x01 | 0x02; // 預設 0x02
}
function encryptPrivateKey(
privateKey: Uint8Array,
password: string,
options: EncryptOptions = {}
): string {
const { logN = 19, keySecurity = 0x02 } = options;
// 1. 正規化密碼
const normalizedPassword = password.normalize('NFKC');
const passwordBytes = new TextEncoder().encode(normalizedPassword);
// 2. 生成隨機 salt 和 nonce
const salt = randomBytes(16);
const nonce = randomBytes(24);
// 3. 使用 scrypt 派生密鑰
const N = Math.pow(2, logN);
const key = scrypt(passwordBytes, salt, { N, r: 8, p: 1, dkLen: 32 });
// 4. 使用 XChaCha20-Poly1305 加密
const aad = new Uint8Array([keySecurity]);
const cipher = xchacha20poly1305(key, nonce, aad);
const ciphertext = cipher.encrypt(privateKey);
// 5. 組合二進制資料
const data = new Uint8Array(91);
data[0] = 0x02; // 版本號
data[1] = logN;
data.set(salt, 2);
data.set(nonce, 18);
data[42] = keySecurity;
data.set(ciphertext, 43);
// 6. Bech32 編碼
const words = bech32.toWords(data);
return bech32.encode('ncryptsec', words, 91 * 2);
}
// 使用範例
import { generateSecretKey } from 'nostr-tools';
const sk = generateSecretKey();
const ncryptsec = encryptPrivateKey(sk, 'my-strong-password');
console.log('加密後的私鑰:', ncryptsec); 解密私鑰
import { scrypt } from '@noble/hashes/scrypt';
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { bech32 } from '@scure/base';
interface DecryptResult {
privateKey: Uint8Array;
keySecurity: number;
}
function decryptPrivateKey(
ncryptsec: string,
password: string
): DecryptResult {
// 1. Bech32 解碼
const decoded = bech32.decode(ncryptsec, 200);
if (decoded.prefix !== 'ncryptsec') {
throw new Error('無效的 ncryptsec 格式');
}
const data = bech32.fromWords(decoded.words);
// 2. 解析各欄位
const version = data[0];
if (version !== 0x02) {
throw new Error(`不支援的版本: ${version}`);
}
const logN = data[1];
const salt = data.slice(2, 18);
const nonce = data.slice(18, 42);
const keySecurity = data[42];
const ciphertext = data.slice(43);
// 3. 正規化密碼並派生密鑰
const normalizedPassword = password.normalize('NFKC');
const passwordBytes = new TextEncoder().encode(normalizedPassword);
const N = Math.pow(2, logN);
const key = scrypt(passwordBytes, new Uint8Array(salt), {
N,
r: 8,
p: 1,
dkLen: 32,
});
// 4. 解密
const aad = new Uint8Array([keySecurity]);
const cipher = xchacha20poly1305(
new Uint8Array(key),
new Uint8Array(nonce),
aad
);
try {
const privateKey = cipher.decrypt(new Uint8Array(ciphertext));
return {
privateKey: new Uint8Array(privateKey),
keySecurity,
};
} catch {
throw new Error('解密失敗:密碼錯誤或資料損壞');
}
}
// 使用範例
try {
const { privateKey, keySecurity } = decryptPrivateKey(
ncryptsec,
'my-strong-password'
);
console.log('解密成功!');
console.log('密鑰安全狀態:', keySecurity);
} catch (error) {
console.error('解密失敗:', error);
} 完整的密鑰管理類別
import { scrypt } from '@noble/hashes/scrypt';
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { randomBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { bech32 } from '@scure/base';
import { getPublicKey, nip19 } from 'nostr-tools';
class EncryptedKeyManager {
private static readonly VERSION = 0x02;
static encrypt(
privateKey: Uint8Array | string,
password: string,
logN: number = 19
): string {
// 處理不同格式的私鑰
let keyBytes: Uint8Array;
if (typeof privateKey === 'string') {
if (privateKey.startsWith('nsec')) {
const decoded = nip19.decode(privateKey);
keyBytes = decoded.data as Uint8Array;
} else {
keyBytes = hexToBytes(privateKey);
}
} else {
keyBytes = privateKey;
}
if (keyBytes.length !== 32) {
throw new Error('私鑰必須是 32 位元組');
}
const normalizedPassword = password.normalize('NFKC');
const passwordBytes = new TextEncoder().encode(normalizedPassword);
const salt = randomBytes(16);
const nonce = randomBytes(24);
const N = Math.pow(2, logN);
const key = scrypt(passwordBytes, salt, { N, r: 8, p: 1, dkLen: 32 });
const keySecurity = 0x02;
const aad = new Uint8Array([keySecurity]);
const cipher = xchacha20poly1305(key, nonce, aad);
const ciphertext = cipher.encrypt(keyBytes);
const data = new Uint8Array(91);
data[0] = this.VERSION;
data[1] = logN;
data.set(salt, 2);
data.set(nonce, 18);
data[42] = keySecurity;
data.set(ciphertext, 43);
// 清除敏感資料
keyBytes.fill(0);
key.fill(0);
const words = bech32.toWords(data);
return bech32.encode('ncryptsec', words, 200);
}
static decrypt(ncryptsec: string, password: string): {
privateKey: Uint8Array;
privateKeyHex: string;
nsec: string;
publicKey: string;
npub: string;
keySecurity: number;
} {
const decoded = bech32.decode(ncryptsec, 200);
if (decoded.prefix !== 'ncryptsec') {
throw new Error('無效的 ncryptsec 格式');
}
const data = new Uint8Array(bech32.fromWords(decoded.words));
if (data[0] !== this.VERSION) {
throw new Error(`不支援的版本: ${data[0]}`);
}
const logN = data[1];
const salt = data.slice(2, 18);
const nonce = data.slice(18, 42);
const keySecurity = data[42];
const ciphertext = data.slice(43);
const normalizedPassword = password.normalize('NFKC');
const passwordBytes = new TextEncoder().encode(normalizedPassword);
const N = Math.pow(2, logN);
const key = scrypt(passwordBytes, salt, { N, r: 8, p: 1, dkLen: 32 });
const aad = new Uint8Array([keySecurity]);
const cipher = xchacha20poly1305(key, nonce, aad);
let privateKey: Uint8Array;
try {
privateKey = cipher.decrypt(ciphertext);
} catch {
throw new Error('解密失敗:密碼錯誤');
}
const privateKeyHex = bytesToHex(privateKey);
const publicKey = getPublicKey(privateKey);
// 清除敏感資料
key.fill(0);
return {
privateKey,
privateKeyHex,
nsec: nip19.nsecEncode(privateKey),
publicKey,
npub: nip19.npubEncode(publicKey),
keySecurity,
};
}
static changePassword(
ncryptsec: string,
oldPassword: string,
newPassword: string,
newLogN?: number
): string {
const { privateKey } = this.decrypt(ncryptsec, oldPassword);
const result = this.encrypt(privateKey, newPassword, newLogN);
// 清除敏感資料
privateKey.fill(0);
return result;
}
}
// 使用範例
import { generateSecretKey } from 'nostr-tools';
const sk = generateSecretKey();
console.log('原始 nsec:', nip19.nsecEncode(sk));
// 加密
const encrypted = EncryptedKeyManager.encrypt(sk, 'my-password', 19);
console.log('ncryptsec:', encrypted);
// 解密
const decrypted = EncryptedKeyManager.decrypt(encrypted, 'my-password');
console.log('解密後 nsec:', decrypted.nsec);
console.log('npub:', decrypted.npub);
// 更改密碼
const newEncrypted = EncryptedKeyManager.changePassword(
encrypted,
'my-password',
'new-password'
);
console.log('新的 ncryptsec:', newEncrypted); 安全考量
重要安全提醒:
- 不要公開發布加密的私鑰,攻擊者可以收集大量加密私鑰進行破解
- 使用強密碼,至少 12 個字元包含大小寫、數字和符號
- 使用完畢後清除記憶體中的密碼和私鑰
- LOG_N 值越高越安全,但解密時間也越長
為什麼選擇這些演算法?
- scrypt:最大化記憶體硬度,有效抵抗 GPU/ASIC 暴力破解
- XChaCha20-Poly1305:現代密碼學家普遍推薦,被 TLS 1.3 和 OpenSSH 採用
使用場景
私鑰備份
- 將加密後的私鑰儲存在密碼管理器中
- 備份到安全的離線儲存
跨裝置傳輸
- 安全地將私鑰從一台設備轉移到另一台
- 透過不安全通道傳輸(但不建議公開發布)
多設備同步
- 在多台設備間同步加密的私鑰
- 使用相同密碼在各設備解密
相關 NIPs
參考資源
已複製連結