跳至主要內容

NIP-55: Android Signer Application

Android 平台的 Nostr 簽名應用程式標準,實現安全的密鑰管理

概述

NIP-55 定義了 Android 平台上 Nostr 簽名應用程式的標準介面。類似於 NIP-07 在瀏覽器中的作用,NIP-55 允許 Android 應用程式將簽名操作委託給專門的簽名應用, 實現密鑰的安全隔離和統一管理。

架構設計

NIP-55 使用 Android 的 Intent 和 Content Provider 機制:

  • 簽名應用(Signer):保管私鑰,處理簽名請求
  • 客戶端應用(Client):發送簽名請求,不接觸私鑰
  • Intent:用於應用間通訊
  • Content Provider:用於返回結果

Intent Actions

簽名應用宣告

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="nostrsigner" />
</intent-filter>

支援的操作

操作 URI Scheme 說明
取得公鑰 nostrsigner:get_public_key 獲取用戶的公鑰
簽名事件 nostrsigner:sign_event 簽署 Nostr 事件
加密訊息 nostrsigner:nip04_encrypt NIP-04 加密
解密訊息 nostrsigner:nip04_decrypt NIP-04 解密
NIP-44 加密 nostrsigner:nip44_encrypt NIP-44 加密
NIP-44 解密 nostrsigner:nip44_decrypt NIP-44 解密

請求格式

取得公鑰

nostrsigner:get_public_key?callback=myapp://callback

簽名事件

nostrsigner:sign_event?event=<base64-encoded-event>&callback=myapp://callback

加密訊息

nostrsigner:nip04_encrypt?pubkey=<recipient-pubkey>&plaintext=<base64-text>&callback=myapp://callback

解密訊息

nostrsigner:nip04_decrypt?pubkey=<sender-pubkey>&ciphertext=<encrypted-text>&callback=myapp://callback

回應格式

簽名應用透過 callback URI 返回結果:

成功回應

myapp://callback?result=<base64-encoded-result>

錯誤回應

myapp://callback?error=user_rejected

常見錯誤碼

錯誤碼 說明
user_rejected 用戶拒絕請求
invalid_event 事件格式無效
no_key 沒有可用的密鑰
decrypt_failed 解密失敗

Content Provider 方式

對於需要同步返回的場景,可使用 Content Provider:

URI 格式

content://com.example.signer/sign
content://com.example.signer/get_public_key
content://com.example.signer/nip04_encrypt
content://com.example.signer/nip04_decrypt

Android 實作

客戶端:發送簽名請求

import android.content.Intent
import android.net.Uri
import android.util.Base64

class NostrSignerClient(private val activity: Activity) {

    companion object {
        const val REQUEST_SIGN = 1001
        const val REQUEST_GET_PUBKEY = 1002
        const val REQUEST_ENCRYPT = 1003
        const val REQUEST_DECRYPT = 1004
    }

    fun getPublicKey(callback: String) {
        val uri = Uri.parse("nostrsigner:get_public_key")
            .buildUpon()
            .appendQueryParameter("callback", callback)
            .build()

        val intent = Intent(Intent.ACTION_VIEW, uri)
        activity.startActivityForResult(intent, REQUEST_GET_PUBKEY)
    }

    fun signEvent(eventJson: String, callback: String) {
        val encodedEvent = Base64.encodeToString(
            eventJson.toByteArray(),
            Base64.NO_WRAP
        )

        val uri = Uri.parse("nostrsigner:sign_event")
            .buildUpon()
            .appendQueryParameter("event", encodedEvent)
            .appendQueryParameter("callback", callback)
            .build()

        val intent = Intent(Intent.ACTION_VIEW, uri)
        activity.startActivityForResult(intent, REQUEST_SIGN)
    }

    fun encrypt(pubkey: String, plaintext: String, callback: String) {
        val encodedText = Base64.encodeToString(
            plaintext.toByteArray(),
            Base64.NO_WRAP
        )

        val uri = Uri.parse("nostrsigner:nip04_encrypt")
            .buildUpon()
            .appendQueryParameter("pubkey", pubkey)
            .appendQueryParameter("plaintext", encodedText)
            .appendQueryParameter("callback", callback)
            .build()

        val intent = Intent(Intent.ACTION_VIEW, uri)
        activity.startActivityForResult(intent, REQUEST_ENCRYPT)
    }

    fun decrypt(pubkey: String, ciphertext: String, callback: String) {
        val uri = Uri.parse("nostrsigner:nip04_decrypt")
            .buildUpon()
            .appendQueryParameter("pubkey", pubkey)
            .appendQueryParameter("ciphertext", ciphertext)
            .appendQueryParameter("callback", callback)
            .build()

        val intent = Intent(Intent.ACTION_VIEW, uri)
        activity.startActivityForResult(intent, REQUEST_DECRYPT)
    }
}

客戶端:處理回應

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    val uri = data?.data ?: return

    // 檢查錯誤
    val error = uri.getQueryParameter("error")
    if (error != null) {
        handleError(error)
        return
    }

    // 取得結果
    val result = uri.getQueryParameter("result") ?: return
    val decoded = Base64.decode(result, Base64.NO_WRAP)
    val resultString = String(decoded)

    when (requestCode) {
        NostrSignerClient.REQUEST_GET_PUBKEY -> {
            // resultString 是公鑰(hex 格式)
            onPublicKeyReceived(resultString)
        }
        NostrSignerClient.REQUEST_SIGN -> {
            // resultString 是簽名後的事件 JSON
            onEventSigned(resultString)
        }
        NostrSignerClient.REQUEST_ENCRYPT -> {
            // resultString 是加密後的文字
            onEncrypted(resultString)
        }
        NostrSignerClient.REQUEST_DECRYPT -> {
            // resultString 是解密後的明文
            onDecrypted(resultString)
        }
    }
}

private fun handleError(error: String) {
    when (error) {
        "user_rejected" -> showToast("用戶取消了操作")
        "invalid_event" -> showToast("事件格式無效")
        "no_key" -> showToast("沒有可用的密鑰")
        else -> showToast("發生錯誤: $error")
    }
}

簽名應用:處理請求

class SignerActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val uri = intent.data ?: return finish()
        val callback = uri.getQueryParameter("callback") ?: return finish()

        when (uri.host) {
            "get_public_key" -> handleGetPublicKey(callback)
            "sign_event" -> handleSignEvent(uri, callback)
            "nip04_encrypt" -> handleEncrypt(uri, callback)
            "nip04_decrypt" -> handleDecrypt(uri, callback)
        }
    }

    private fun handleGetPublicKey(callback: String) {
        // 顯示確認對話框
        showConfirmDialog("允許應用獲取您的公鑰?") { confirmed ->
            if (confirmed) {
                val pubkey = keyManager.getPublicKey()
                returnResult(callback, pubkey)
            } else {
                returnError(callback, "user_rejected")
            }
        }
    }

    private fun handleSignEvent(uri: Uri, callback: String) {
        val encodedEvent = uri.getQueryParameter("event") ?: return
        val eventJson = String(Base64.decode(encodedEvent, Base64.NO_WRAP))
        val event = parseEvent(eventJson)

        // 顯示事件預覽和確認對話框
        showSignConfirmDialog(event) { confirmed ->
            if (confirmed) {
                val signedEvent = keyManager.signEvent(event)
                returnResult(callback, signedEvent.toJson())
            } else {
                returnError(callback, "user_rejected")
            }
        }
    }

    private fun returnResult(callback: String, result: String) {
        val encoded = Base64.encodeToString(result.toByteArray(), Base64.NO_WRAP)
        val uri = Uri.parse(callback)
            .buildUpon()
            .appendQueryParameter("result", encoded)
            .build()

        val intent = Intent(Intent.ACTION_VIEW, uri)
        startActivity(intent)
        finish()
    }

    private fun returnError(callback: String, error: String) {
        val uri = Uri.parse(callback)
            .buildUpon()
            .appendQueryParameter("error", error)
            .build()

        val intent = Intent(Intent.ACTION_VIEW, uri)
        startActivity(intent)
        finish()
    }
}

AndroidManifest.xml 配置

<!-- 簽名應用 -->
<activity
    android:name=".SignerActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="nostrsigner" />
    </intent-filter>
</activity>

<!-- 客戶端應用 - 接收回調 -->
<activity
    android:name=".CallbackActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="callback" />
    </intent-filter>
</activity>

安全考量

密鑰保護

  • 私鑰應儲存在 Android Keystore
  • 使用生物識別保護密鑰存取
  • 永遠不要將私鑰傳輸給其他應用

請求驗證

  • 顯示請求來源應用的資訊
  • 讓用戶確認每個簽名請求
  • 提供事件內容預覽

權限管理

  • 可以記住用戶對特定應用的授權
  • 提供撤銷授權的功能
  • 定期清理過期授權

使用場景

統一密鑰管理

  • 一個簽名應用管理所有 Nostr 密鑰
  • 多個客戶端應用共享同一身份
  • 安全備份和恢復

增強安全性

  • 客戶端應用不接觸私鑰
  • 減少私鑰洩露風險
  • 硬體安全模組整合

相容應用

簽名應用

  • Amber
  • 其他實現 NIP-55 的簽名應用

客戶端應用

  • Amethyst
  • 其他支援 NIP-55 的 Nostr 客戶端

最佳實踐

  • 用戶確認:每次簽名都應要求用戶確認
  • 清晰預覽:顯示即將簽名的事件內容
  • 錯誤處理:優雅處理各種錯誤情況
  • 後備方案:無簽名應用時提供替代方案
  • 回調安全:驗證回調 URI 的合法性
  • NIP-01:基本協議 - 事件簽名
  • NIP-04:加密私訊 - 加密格式
  • NIP-07:瀏覽器擴充 - 類似概念
  • NIP-44:加密訊息 v2 - 新加密標準
  • NIP-46:遠端簽名 - Nostr Connect

參考資源

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