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 的合法性
相關 NIPs
- NIP-01:基本協議 - 事件簽名
- NIP-04:加密私訊 - 加密格式
- NIP-07:瀏覽器擴充 - 類似概念
- NIP-44:加密訊息 v2 - 新加密標準
- NIP-46:遠端簽名 - Nostr Connect
參考資源
已複製連結