跳至主要內容

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 採用

使用場景

私鑰備份

  • 將加密後的私鑰儲存在密碼管理器中
  • 備份到安全的離線儲存

跨裝置傳輸

  • 安全地將私鑰從一台設備轉移到另一台
  • 透過不安全通道傳輸(但不建議公開發布)

多設備同步

  • 在多台設備間同步加密的私鑰
  • 使用相同密碼在各設備解密
  • NIP-01:基本協議 - 私鑰和簽名
  • NIP-06:助記詞派生 - 另一種密鑰備份方式
  • NIP-19:bech32 編碼 - nsec 格式
  • NIP-44:加密訊息 - 相關加密技術

參考資源

已複製連結
已複製到剪貼簿