NIP-44
Encrypted Payloads (Versioned)
暗号化アルゴリズムをバージョンを管理できるようにした暗号方法を定義する仕様です。
従来の「暗号化されたダイレクトメッセージ(NIP-04)」の代替となります。 この仕様は暗号化方式のみを定義しており、DMの仕様自体はNIP-17で定義されています。 /icons/hr.icon
翻訳 commit=2409f82(2024/1/6)(翻訳者は暗号の専門家でないため、原文も参照してください)
(バージョン管理された)暗号化されたペイロード
任意
このNIP(訳注:仕様)はキー-ペアに基づく暗号化のためのデータフォーマットを定める。このNIPは複数のアルゴリズムの選択肢が同時に存在できるようにバージョンが付けられる。このフォーマットは様々なことに利用されるだろうが、NIP-01で説明されている署名されたイベントの文脈で利用しなければならない(MUST)(訳注: イベント以外の用途で使用すべきではないという意味だと思われる)。 注意: このフォーマットは新しいダイレクトメッセージ標準(それを定義するために必要な暗号化だけ)に関するいかなるkindも定義しない。NIP-04ペイロードの完全互換版として使うべきではない(SHOULD NOT)。 バージョン
現在定義されている暗号化アルゴリズムは以下の通り:
0x00 - 予約済み
0x01 - 廃止済みで、未定義
0x02 - secp256k1 ECDH(楕円曲線ディフィー・ヘルマン鍵共有)、HKDF(HMAC鍵導出関数)、パディング、ChaCha20、HMAC-SHA256、Base64
制約
すべてのnostrユーザは自身の公開鍵を持っている。これが他の手段に存在する鍵配送問題を解決している。しかしながら、nostrのリレーを基本とするアーキテクチャがメタデータの隠蔽や前方秘匿性(Forward secrecy)、情報漏洩後の秘匿性を備えたより堅牢なプライベートメッセージプロトコルの実装を困難なものにしている。
このNIPのゴールは署名されたイベントの文脈で利用されるペイロードを暗号化するシンプルな方法を獲得することである。このNIPを適用するどんなユースケースであってもユーザの脅威モデルとNIPの制約について心に留めておくことが重要である。高リスクな状況ではユーザは専用のE2EEメッセージングソフトウェアでチャットすべきであり、nostrの使用は連絡先の交換に留めるべきである。
自然発生的にこのスキームを用いて送信されたメッセージにはいくつかの重要な欠点がある:
否認性の欠如:イベントが特定のキーで署名されたことを証明することができる
前方秘匿性の欠如:キーが漏洩した場合、以前のすべての会話を復号できる
情報漏洩後の秘匿性の欠如:キーが漏洩した場合、将来のすべての会話を複合できる
量子コンピュータ以降のセキュリティの欠如:強力な量子コンピュータがメッセージを暗号化できることだろう
IPアドレスの漏洩:ユーザのIPはリレーおよびリレーとユーザ間にある中間装置によって観測できる
日付の漏洩:created_atは公開情報である(NIP-01が定義するイベントの一部であるため)
限定的なメッセージサイズの漏洩:パディングは本当のメッセージ長を部分的に分かりづらくするに過ぎない
添付方法の欠如:サポートされない
前方秘匿性の欠如は信頼できるリレーにのみメッセージを送ること、一定の日数が経過した後に保管したメッセージを削除するようリレーに頼むことで部分的に軽減されるだろう。
バージョン2
NIP-44 バージョン2は次の設計上の特徴がある:
ペイロードは署名後でなく署名前にMACによって認証される。イベントはNIP-01で説明されているとおりに署名されることが想定されているためである。外部の署名はペイロード全体の認証を提供し、復号前に検証されなければならない。
XChaChaは標準化されていないため、ChaChaが代わりに使われる。また、すべてのメッセージが新しいペア(鍵とnonce)を持つため、xChaChaにおけるnonceの衝突耐性の改善は必要とされない。
HMAC-SHA256はPoly1305の代わりに使われる。多項式MACは偽造がより簡単であるためである。
SHA-256がSHA3またはBLAKEの代わりに使われる。nostrで既に利用されているためである。また、BLAKEの速度の利点は非並列環境では小さいため。
カスタムのパディング方式がpadméの代わりに使われる。小さなメッセージにおいてよりよい漏洩軽減を提供するためである。
Base64エンコードが他の圧縮アルゴリズムの代わりに使われる。多くの環境で利用でき、nostrでもすでに使われているためである。
暗号化
1. 対話鍵(convesation key)の計算
秘密鍵Aによる公開鍵BのECDH(スカラー乗法)を実施する。出力shared_xはハッシュ化されず、共通点(shared point)の32バイトのエンコードされたx座標でなければならない。
HKDFの抽出(extract)を用いる(SHA-256と共に)。IKM=shared_xで、salt=utf8_encode('nip44-v2')である。
HKDFの出力は二者間のconversation_keyとなる。
それは鍵の役割が逆になっても常に同じである: conv(a, B) == conv(b, A)
2. ランダムな32バイトの nonce の生成
メッセージの内容から nonce を生成してはならない。
別のメッセージの nonce を再利用してはならない。もし再利用すると、2つのメッセージを復号できるようにしてしまう。しかし、長期キーが漏洩するわけではない。
3. メッセージ鍵の算出
鍵はconversation_keyとnonceから生成される。長さが32バイトであることを検証せよ。
HKDF展開(expand)を用いる(SHA-256と共に)。PRK=conversation_key、info=nonce、L=76
76バイトのHKDF出力をスライスすること:chacha_key(0〜32バイト)、chacha_nonce(32〜44バイト)、hmac_key(44〜76バイト)
4. パディングの追加
内容はUTF-8からバイト配列へとエンコードしなければならない
平文の長さを検証すること。最小は1バイト、最長で65535バイトである。
パディングの形式は [平文の長さ: u16][平文本体][ゼロ埋め]
パディングアルゴリズムは2のべき乗に従い、最小のパディングされたメッセージのサイズは32バイトである。
平文の長さはビッグエンディアンでエンコードされる(パディングされたBLOBの最初の2バイトとして)。
5. パディングされた内容の暗号化
ChaCha20を使用すること。手順3の鍵とnonceを用いること。
6. MAC(メッセージ認証符号)の計算
AAD(Added authenticated data、追加認証データ)が使われる。暗号文のMACを計算するかわりに、それはnonceとciphertextの連結に対して計算される。
AAD(nonce)が32バイトであることを検証せよ。
7. Base64エンコード(パディングと共に)をconcat(version, nonce, ciphertext, mac)に対して行う
暗号化されたペイロードは、NIP-01で定義されているように、secp256k1のシュノア署名方式を用いて、イベントのペイロードに含められ、ハッシュ化され、署名されなければならない(MUST)。
復号
復号の前に、NIP-01で定められているようにイベントの公開鍵と署名を検証しなければならない(MUST)。公開鍵は、有効なゼロでないsecp256k1曲線上の点であり、署名は有効なsecp256k1署名でなければならない(MUST)。正確な検証ルールについては、BIP-340を参照せよ。 1. 最初のペイロードの文字が#であることの確認
#は任意の将来においても有効な(時代遅れにならない、future-proof)フラグであり、非Base64エンコーディングが使われていることを意味する。
#はBase64の文字種に存在しないが、base64 is invalid(base64が無効)というエラーを投げるかわりに、実装はその暗号化バージョンが未対応であることを示さなければならない(MUST)。
2. Base64のデコード
Base64はversion, nonce, ciphertext, macの組へとデコードされる。
バージョンが不明の場合、その暗号化バージョンに未対応であることを示さなければならない。
Base64デコーダに対するDoSを防ぐため、Base64メッセージの長さを検証すること:132〜87472文字の間であること。
デコーダの出力を検証するため、デコード済みメッセージの長さを検証すること:99〜65603バイトの間であること。
3. 対話鍵の計算
暗号化の手順1を参照せよ。
4. メッセージ鍵の計算
暗号化の手順3を参照せよ。
5. AADを伴うMAC(メッセージ認証符号)の計算と比較
MACが手順2のものと一致しない場合、停止してエラーを投げること。
定数時間の比較アルゴリズムを用いること。
6. 暗号文の復号
ChaCha20を使用すること。手順3の鍵とnonceを用いること。
7. パディングの除去
平文の長さに対応する、平文の最初の2バイト(ビッグエンディアン)を読み取ること。
スライスされた平文の長さがその2バイトの値と一致することを検証せよ。
暗号化手順3で計算されたパディングと実際のパディングが一致することを検証せよ。
詳細
暗号のメソッド
secure_random_bytes(length) : CSPRNG(暗号論的擬似乱数生成器)から乱数性を取得する。
hkdf(IKM, salt, info, L) : SHA-256ハッシュ関数を伴う、HKDF(HMAC鍵導出関数) (RFC 5869) を表す。 hkdf_extract(IKM, salt) と hkdf_expand(OKM, info, L)の2つの関数で構成される。 chacha20(key, nonce, data) : ChaCha20 (RFC 8439)。開始カウンタは0に設定される。 hmac_sha256(key, message) : HMAC (RFC 2104)。 secp256k1_ecdh(priv_a, pub_b) : BIP-340で定義されている、点 B のスカラー値 a による乗算(a ⋅ B)を表す。演算は共通点(shared point)を生成し、(BIP-340のbytes(P)の手法を用いて)共通点の x 座標(32バイト)をエンコードする。秘密鍵と公開鍵は、BIP-340に従って検証されなければならない。公開鍵は有効(valid)であり、曲線上の点であり、秘密鍵は [1, secp256k1_order - 1]の間のスカラー値でなければならない。 演算子
x[i:j] (xはバイト配列、i, j <= 0): x の i番目のバイト(これを含む)からj番目(これを含まない)までをコピーした (j - i) バイト配列を返す。
定数 c
min_plaintext_size(最小平文長) : 1 である。1バイトのメッセージは32バイトにパディングされる。
max_plaintext_size(最大平文長) : 65535 (64KB - 1) である。65536にパディングされる。
関数
base64_encode(string) と base64_decode(bytes) : Base64 (RFC 4648、パディングを伴う)。 concat : バイト配列の連結を意味する。
is_equal_ct(a, b) : 2つのバイト配列に対する定数時間の同一性検査。
utf8_encode(string) と utf8_decode(bytes) : 文字列をバイト配列に変換/逆変換する。
write_u8(number): 数値を0〜255の値に制限し、uint8をビッグエンディアンのバイト配列にエンコードする。
write_u16_be(number) : 数値を0〜65535の値に制限し、uint16をビッグエンディアンのバイト配列にエンコードする。
zeros(length) : 長さが length >= 0 である、ゼロ埋めされたバイト配列を生成する。
floor(number) と log2(number) : よく知られた数学的なメソッド
実装の疑似コード
code:_.py
# パディングされた場合のバイト配列の長さを計算する
def calc_padded_len(unpadded_len):
next_power = 1 << (floor(log2(unpadded_len - 1))) + 1
if next_power <= 256:
chunk = 32
else:
chunk = next_power / 8
if unpadded_len <= 32:
return 32
else:
return chunk * (floor((len - 1) / chunk) + 1)
# パディングされてない平文をパディング済みのバイト配列に変換する
def pad(plaintext):
unpadded = utf8_encode(plaintext)
unpadded_len = len(plaintext)
if (unpadded_len < c.min_plaintext_size or
unpadded_len > c.max_plaintext_size): raise Exception('平文の長さが不正')
prefix = write_u16_be(unpadded_len)
suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len)
return concat(prefix, unpadded, suffix)
# パディング済みのバイト配列をパディングされていない平文に変換する
def unpad(padded):
unpadded_len = read_uint16_be(padded0:2) if (unpadded_len == 0 or
len(unpadded) != unpadded_len or
len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('不正なパディング')
return utf8_decode(unpadded)
# メタデータ : 常に 65バイト (version: 1バイト, nonce: 32バイト, max: 32バイト)
# 平文 : 1 〜 65535(0xffff)バイト
# パディング済みの平文 : 32 〜 65535(0xffff)バイト
# 暗号文: 32 + 2 〜 65535(0xffff) + 2 バイト
# 生のペイロード: 99 (65+32+2) 〜o 65603 (65+65535(0xffff)+2) バイト
# 圧縮されたペイロード (base64): 132 〜 87472b
def decode_payload(payload):
plen = len(payload)
if plen == 0 or payload0 == '#': raise Exception('未知のバージョン') if plen < 132 or plen > 87472: raise Exception('ペイロード長が不正')
data = base64_decode(payload)
dlen = len(d)
if dlen < 99 or dlen > 65603: raise Exception('データ長が不正');
if vers != 2: raise Exception('未知の―ジョン ' + vers)
return (nonce, ciphertext, mac)
def hmac_aad(key, message, aad):
if len(aad) != 32: raise Exception('AAD関連データは 32 バイトでなければならない');
return hmac(sha256, key, concat(aad, message));
# Calculates long-term key between users A and B: get_key(Apriv, Bpub) == get_key(Bpriv, Apub)
def get_conversation_key(private_key_a, public_key_b):
shared_x = secp256k1_ecdh(private_key_a, public_key_b)
return hkdf_extract(IKM=shared_x, salt=utf8_encode('nip44-v2'))
# Calculates unique per-message key
def get_message_keys(conversation_key, nonce):
if len(conversation_key) != 32: raise Exception('対話鍵(conversation_key)の長さが不正')
if len(nonce) != 32: raise Exception('nonceの長さが不正')
keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76)
return (chacha_key, chacha_nonce, hmac_key)
def encrypt(plaintext, conversation_key, nonce):
(chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
padded = pad(plaintext)
ciphertext = chacha20(key=chacha_key, nonce=chacha_nonce, data=padded)
mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
return base64_encode(concat(write_u8(2), nonce, ciphertext, mac))
def decrypt(payload, conversation_key):
(nonce, ciphertext, mac) = decode_payload(payload)
(chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
calculated_mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
if not is_equal_ct(calculated_mac, mac): raise Exception('invalid MAC')
padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext)
return unpad(padded_plaintext)
# 使用法:
# conversation_key = get_conversation_key(sender_privkey, recipient_pubkey)
# nonce = secure_random_bytes(32)
# payload = encrypt('hello world', conversation_key, nonce)
# 'hello world' == decrypt(payload, conversation_key)
監査
テストとコード
code:sha256sum
269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040 nip44.vectors.json
ファイルからのテストベクタの例:
code:_.json
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
}
ファイルには中間の値も含まれる。使用法についての手早いガイド:
valid.get_conversation_key: 秘密鍵 sec1 と 公開鍵 pub2 から対話鍵(conversation_key)を計算
valid.get_message_keys: 対話鍵とnonceから chacha_key、chacha_nonce、hmac_keyを計算
valid.calc_padded_len: パディングされていない長さ(最初の値)を受け取り、パディング済みの長さを計算する(2つ目の値)
valid.encrypt_decrypt: 本物の対話をエミュレートする。sec2からpub2を求め、(sec1, pub2)から対話鍵(conversation_key)を検証し、暗号化し、ペイロードを検証する。そして、sec1からpub1を求め、(sec2, pub1)から対話鍵(conversation_key)を検証し、復号し、平文を検証する。
valid.encrypt_decrypt_long_msg: 前と同様だが、完全な平文とペイロードを提供する代わりにチェックサムを提供する。
invalid.encrypt_msg_lengths
(訳注: 原文でも未記載)
invalid.get_conversation_key: エラーを投げるべきconversation_keyの計算
invalid.decrypt: エラーを投げるべきメッセージ内容の復号
/icons/hr.icon
策定中段階の古いバージョンについての翻訳 (kaiji) 特長
暗号の強化: NIP-04の欠点を解消し、パディングオラクル攻撃からの耐性を向上する。
コンテンツとタグの構造: イベントには、バージョン、nonce、暗号文などの属性が含まれるべきで、タグには受信者や返信先の情報が入る。
バージョン管理: クライアントはサポートしていないバージョンのメッセージを受信するとエラーを返し、ユーザーに対応するためのアクションを提案する。
Lists
1. 暗号の強化: NIP4の欠点を解消し、パディングオラクル攻撃からの耐性を向上する。
2. 新しいイベント: kind: 44で、暗号化されたダイレクトメッセージということを示す。
3. コンテンツとタグの構造: イベントには、バージョン、nonce、暗号文などの属性が含まれるべきで、タグには受信者や返信先の情報が入る。
4. メタデータの漏洩を防ぐ: クライアントは特定のフローを実装する必要があり、認証とNIP-65の使用を通じて、メタデータの漏洩を抑制できる。 5. バージョン管理: クライアントはサポートしていないバージョンのメッセージを受信するとエラーを返し、ユーザーに対応するためのアクションを提案する。
このNIPは、暗号化アルゴリズムの選択が不可能な NIP-04(パディング オラクル攻撃に対して潜在的に脆弱であり、ランダムなキーと区別できないキーを使用している)を置き換え、暗号化のバージョン管理を導入することを目的としている。 kind:44の特別なイベントは、「暗号化されたダイレクト メッセージ」を意味し、このイベントでは、次のような属性を持つことが想定されている。
content はCSV形式 v,param1,param2... で記述されている必要がある。バージョンが異なるとパラメーター数が異なる場合がある。最初の部分は常に数値型の暗号化アルゴリズムのバージョンである。(MUST)
Version: 1のパラメーター
1. nonce: base64でエンコードされたxchachaアルゴリズムのnonce値
2. ciphertext: plaintext に対して (key, nonce) から作成された、base64 でエンコードされた xchacha暗号文(例:1,3dBKd83Pg2Q4Tu2A2e8N++c+ZW2IBc2f,FvQi1H4atMwU+FzUR/0CJ7kowjs+)
tags には ["p", "<pubkey, as a hex string>"] の形式で、メッセージの受信者の識別子(リレーがこのイベントを自然に転送できるように) が含まれている必要がある。(MUST)
tags には ["e", "<event_id>"] の形式で、会話内の前のメッセージの識別子、または明示的に返信しているメッセージ (状況に応じて、より組織化された会話が発生する可能性がある) を含めることができる。(MAY)
注: libsecp256k1 ECDH 実装のデフォルトでは、シークレットは共有ポイントの SHA256 ハッシュ (X 座標と Y 座標の両方) である。NIP-4では、ハッシュ化されていない共有ポイントが使用されていないが、このNIPでは正確な実装を使用している。 メタデータの漏洩
すべての nostr イベントはインターネット経由で渡されるため、一部のメタデータは常に漏洩する可能性がある。
互換性のあるすべてのクライアントは、メタデータの漏洩を防ぐために次のフローを実装する必要がある。
1. アリスは、NIP-42 を使用して wss://nostr.example.com で認証する。これは、メッセージの送受信ができるのは、適切な認証されたユーザーのみであることを意味する。 2. 成功すると、アリスは優先リレーとして wss://nostr.example.com を指定して NIP-65 イベントを作成しする。設定はパブリックであり、ネットワークはアリスの優先 DM リレーを認識する。 ユーザーが NIP-65 優先リレーを指定しなかった場合、ダイレクト メッセージを受信すべきではない。 (SHOULD NOT)
バージョン管理
クライアントは、サポートしていないバージョンの NIP44 メッセージを受信した場合、説明的なエラーを返す必要がある。エラーは、メッセージのバージョンがサポートされていないことを示し、アップグレードや別のクライアントへの切り替えなどの適切なアクションを提案する必要がある。 (MUST)
現在定義されている暗号化アルゴリズム:
0x00 - 予約済み
0x01 - 会話ごとに同じキー sha256(ecdh) を持つXChaCha
セキュリティに関する警告
この標準は、ピア間の暗号化通信において最先端と考えられているものには程遠いものであり、イベント内のメタデータが漏洩するため、本当に機密にする必要があるものには使用できない。また、kind:4 イベントを取得できるユーザーを制限するために AUTH を使用するリレーでのみ使用すべきである。
クライアント実装に関する警告
クライアントは、 .content から公開キーやメモ参照を検索および置換してはならない。通常のテキスト メモのように処理すると ( @npub... が ["p", "..."] タグを持つ #[0] に置き換えられてしまう)、タグが漏洩し、言及されたユーザーにはメッセージを送信すべき。
アルゴリズム
code: (tsx)
// npm install @noble/curves @noble/hashes @scure/base @stablelib/xchacha20
import {xchacha20} from '@noble/ciphers/chacha'
import {secp256k1} from '@noble/curves/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {randomBytes} from '@noble/hashes/utils'
import {base64} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts'
export function getConversationKey(privkeyA: string, pubkeyB: string): Uint8Array {
const key = secp256k1.getSharedSecret(privkeyA, '02' + pubkeyB)
return sha256(key.subarray(1, 33))
}
export function encrypt(
key: Uint8Array,
text: string,
ver = 1
): string {
if (ver !== 1) throw new Error('NIP44: unknown encryption version')
let nonce = randomBytes(24)
let plaintext = utf8Encoder.encode(text)
let ciphertext = xchacha20(key, nonce, plaintext, plaintext)
return 1,${base64.encode(nonce)},${base64.encode(ciphertext)}
}
export function decrypt(key: Uint8Array, data: string): string {
let dt = data.split(',')
if (dt.length !== 3) throw new Error('NIP44: unknown encryption version');
let v = Number.parseInt(dt0) if (v !== 1) throw new Error('NIP44: unknown encryption version')
let nonce = base64.decode(dt1) let ciphertext = base64.decode(dt2) let plaintext = xchacha20(key, nonce, ciphertext)
let text = utf8Decoder.decode(plaintext)
return text
}