Merge 698f911edd63430c56374eed8f35a193b6ff53f0 into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Taksh Kothari 2026-08-12 00:01:30 +00:00 committed by GitHub
commit e223044648
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1131 additions and 0 deletions

View File

@ -63,6 +63,14 @@ final class VerificationModel: ObservableObject {
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
}
func identityVerificationCard() -> IdentityVerificationCard {
IdentityVerificationCard.current(
nickname: currentNickname,
npub: try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub,
noiseFingerprint: chatViewModel.getMyFingerprint()
)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid

View File

@ -0,0 +1,71 @@
//
// IdentityVerificationCard.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Public-only identity card for out-of-band verification (#183).
///
/// Contains nickname, npub, and Noise fingerprint — never private key material.
/// Passphrase-sealed backup export remains future work; this card is the safe
/// first step for sharing who you are on bitchat.
struct IdentityVerificationCard: Equatable {
let nickname: String
let npub: String?
let noiseFingerprint: String
var plainText: String {
var lines: [String] = [
String(
localized: "identity.card.title",
defaultValue: "bitchat identity card",
comment: "Title line when exporting a public identity verification card"
),
"",
String(
format: String(
localized: "identity.card.nickname",
defaultValue: "nickname: %@",
comment: "Nickname line on the public identity card; %@ is the nickname"
),
locale: .current,
nickname
),
String(
format: String(
localized: "identity.card.fingerprint",
defaultValue: "noise fingerprint: %@",
comment: "Fingerprint line on the public identity card; %@ is the hex fingerprint"
),
locale: .current,
noiseFingerprint
)
]
if let npub, !npub.isEmpty {
lines.append(
String(
format: String(
localized: "identity.card.npub",
defaultValue: "npub: %@",
comment: "Nostr npub line on the public identity card; %@ is the npub"
),
locale: .current,
npub
)
)
}
return lines.joined(separator: "\n")
}
static func current(nickname: String, npub: String?, noiseFingerprint: String) -> IdentityVerificationCard {
IdentityVerificationCard(
nickname: nickname,
npub: npub,
noiseFingerprint: noiseFingerprint
)
}
}

View File

@ -13393,6 +13393,936 @@
}
}
},
"identity.card.copy" : {
"comment" : "Button that copies the public identity verification card as plain text",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "نسخ بطاقة الهوية"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "পরিচয় কার্ড কপি করুন"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "identitätskarte kopieren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "copy identity card"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar tarjeta de identidad"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "کپی کارت هویت"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopyahin ang identity card"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "copier la carte d'identité"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "העתקת כרטיס הזהות"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "पहचान कार्ड कॉपी करें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "salin kartu identitas"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "copia scheda identità"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "身元カードをコピー"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "신원 카드 복사"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "salin kad identiti"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "परिचय कार्ड प्रतिलिपि गर्नुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "identiteitskaart kopiëren"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopiuj kartę tożsamości"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar cartão de identidade"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "copiar cartão de identidade"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "копировать карточку личности"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "kopiera identitetskortet"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "அடையாள அட்டையை நகலெடு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "คัดลอกบัตรประจำตัว"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "kimlik kartını kopyala"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "копіювати картку особи"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "شناختی کارڈ کاپی کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "sao chép thẻ danh tính"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "复制身份卡"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "複製身分卡"
}
}
}
},
"identity.card.fingerprint" : {
"comment" : "Fingerprint line on the public identity card; %@ is the hex fingerprint",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "بصمة noise: %@"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise ফিঙ্গারপ্রিন্ট: %@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise-fingerabdruck: %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise fingerprint: %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "huella noise: %@"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "اثر انگشت noise: %@"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "fingerprint ng noise: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "empreinte noise : %@"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "טביעת אצבע noise: %@"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise फ़िंगरप्रिंट: %@"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "sidik jari noise: %@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "impronta noise: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise フィンガープリント:%@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise 지문: %@"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "cap jari noise: %@"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise फिंगरप्रिन्ट: %@"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise-vingerafdruk: %@"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "odcisk noise: %@"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "impressão digital noise: %@"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "impressão digital noise: %@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "отпечаток noise: %@"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise-fingeravtryck: %@"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise கைரேகை: %@"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลายนิ้วมือ noise: %@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise parmak izi: %@"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "відбиток noise: %@"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise فنگر پرنٹ: %@"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "dấu vân tay noise: %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise 指纹:%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "noise 指紋:%@"
}
}
}
},
"identity.card.nickname" : {
"comment" : "Nickname line on the public identity card; %@ is the nickname",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "الاسم المستعار: %@"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "ডাকনাম: %@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "spitzname: %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "nickname: %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "apodo: %@"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "نام مستعار: %@"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "palayaw: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "pseudo : %@"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "כינוי: %@"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "उपनाम: %@"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "nama panggilan: %@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "soprannome: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ニックネーム:%@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "닉네임: %@"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "nama panggilan: %@"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "उपनाम: %@"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "bijnaam: %@"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "pseudonim: %@"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "apelido: %@"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "apelido: %@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "псевдоним: %@"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "smeknamn: %@"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "புனைப்பெயர்: %@"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ชื่อเล่น: %@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "takma ad: %@"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "псевдонім: %@"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "عرفی نام: %@"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "biệt danh: %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "昵称:%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "暱稱:%@"
}
}
}
},
"identity.card.npub" : {
"comment" : "Nostr npub line on the public identity card; %@ is the npub",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub : %@"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub:%@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub: %@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub:%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "npub:%@"
}
}
}
},
"identity.card.title" : {
"comment" : "Title line when exporting a public identity verification card",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "بطاقة هوية bitchat"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat পরিচয় কার্ড"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat-identitätskarte"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat identity card"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "tarjeta de identidad de bitchat"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "کارت هویت bitchat"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "identity card ng bitchat"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "carte d'identité bitchat"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "כרטיס זהות של bitchat"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat पहचान कार्ड"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "kartu identitas bitchat"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "scheda identità bitchat"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat 身元カード"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat 신원 카드"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "kad identiti bitchat"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat परिचय कार्ड"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat-identiteitskaart"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "karta tożsamości bitchat"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "cartão de identidade bitchat"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "cartão de identidade do bitchat"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "карточка личности bitchat"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat-identitetskort"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat அடையாள அட்டை"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "บัตรประจำตัว bitchat"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat kimlik kartı"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "картка особи bitchat"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat شناختی کارڈ"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "thẻ danh tính bitchat"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat 身份卡"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "bitchat 身分卡"
}
}
}
},
"image_picker.none_selected" : {
"comment" : "Placeholder shown in the image picker before a selection",
"extractionState" : "manual",

View File

@ -460,6 +460,17 @@ struct VerificationSheetView: View {
// Centered controls moved up
VStack(spacing: 10) {
if !showingScanner {
Button(action: copyIdentityVerificationCard) {
Label(
String(localized: "identity.card.copy", defaultValue: "copy identity card", comment: "Button that copies the public identity verification card as plain text"),
systemImage: "doc.on.doc"
)
.bitchatFont(size: 13)
}
.buttonStyle(.bordered)
}
if showingScanner {
Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode")
@ -492,4 +503,15 @@ struct VerificationSheetView: View {
.themedSheetBackground()
.onDisappear { showingScanner = false }
}
private func copyIdentityVerificationCard() {
let text = verificationModel.identityVerificationCard().plainText
#if os(iOS)
UIPasteboard.general.string = text
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
#endif
}
}

View File

@ -0,0 +1,69 @@
//
// IdentityVerificationCardTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
@testable import bitchat
struct IdentityVerificationCardTests {
@Test func plainTextIncludesPublicFieldsOnly() {
let card = IdentityVerificationCard.current(
nickname: "alice",
npub: "npub1test",
noiseFingerprint: "abcd1234"
)
let text = card.plainText
#expect(text.contains("alice"))
#expect(text.contains("npub1test"))
#expect(text.contains("abcd1234"))
#expect(!text.lowercased().contains("private key"))
#expect(!text.lowercased().contains("seed"))
}
/// The signed QR URL is deliberately absent. `verifyScannedQR` rejects a
/// payload older than `verificationQRMaxAgeSeconds` (5 minutes), so a URL
/// on a card people keep and forward would stop verifying almost at once
/// while still reading as the card's most authoritative line.
@Test func plainTextOmitsTheExpiringVerificationURL() {
let text = IdentityVerificationCard.current(
nickname: "alice",
npub: "npub1test",
noiseFingerprint: "abcd1234"
).plainText
#expect(!text.contains("bitchat://"))
#expect(!text.lowercased().contains("scan to verify"))
}
/// No npub yet means no npub line — not an empty one.
@Test func plainTextOmitsTheNpubLineWhenThereIsNoNpub() {
let withNpub = IdentityVerificationCard.current(
nickname: "alice",
npub: "npub1test",
noiseFingerprint: "abcd1234"
).plainText
let withoutNpub = IdentityVerificationCard.current(
nickname: "alice",
npub: nil,
noiseFingerprint: "abcd1234"
).plainText
#expect(withNpub.contains("npub1test"))
#expect(!withoutNpub.contains("npub"))
#expect(withoutNpub.contains("alice"))
#expect(withoutNpub.contains("abcd1234"))
}
/// An empty npub string is the same as none — an empty line would look
/// like a missing key rather than an absent identity.
@Test func plainTextTreatsAnEmptyNpubAsAbsent() {
let text = IdentityVerificationCard.current(
nickname: "alice",
npub: "",
noiseFingerprint: "abcd1234"
).plainText
#expect(!text.contains("npub"))
}
}

View File

@ -0,0 +1,31 @@
# Identity verification card export (#183)
## Scope (this PR)
Public **identity verification card** export only:
- Nickname
- Noise fingerprint
- Nostr npub (when available)
Available from the verification sheet via **copy identity card**.
## Why the signed QR URL is not on the card
The verification sheet's QR encodes a signed `bitchat://verify?...` URL, and it
would be the natural thing to put on a text card. It is deliberately left off:
`VerificationService.verifyScannedQR` rejects a payload older than
`TransportConfig.verificationQRMaxAgeSeconds` (5 minutes), so a URL copied onto
a card people keep and forward would stop verifying almost immediately — while
still reading as the most authoritative line on it.
The fingerprint and npub have no expiry, which is what makes them the right
fields for an out-of-band card. Scan the QR for a live, signed exchange; use the
card when the other person is not in front of a screen.
## Out of scope (future)
- Passphrase-sealed Noise identity backup / restore (see open PR #1520)
- Private key or seed export in any form
The card gives people a safe, shareable way to confirm who they are out-of-band before marking a fingerprint verified.