Files
buzz/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Bech32Tests.swift
Logan Johnson 93761e411c fix(mobile): render push notification sender identity as npub (#7494)
🤖

## Summary

When an iOS push notification comes from someone the app has no cached
name for, the notification title showed the first characters of the
sender's raw public key — for example `aa4fc866…`. That fragment is
unreadable and doesn't match how the same person appears anywhere else
in Buzz. This PR changes that title to the compact form of the sender's
npub (npub is the human-readable encoding of a Nostr public key): first
8 and last 4 characters — for example `npub14f8…9nsy`, the same identity
shape used across the desktop and mobile apps.

- Unnamed senders: raw hex fragment → compact npub.
- Named senders: unchanged — a sender the app has a display name for
still titles the notification with that name.
- Unverifiable sender identities (malformed keys, or lookalike strings
that are not literal 64-hex-digit keys) now render a neutral "Someone"
instead of partial raw key material.
- Everything else about the notification is unchanged: body text,
subtitle, thread matching and grouping, deep-link navigation, thread
identifiers, and the internal hex public key the resolver matches on.

The native iOS notification-service package (`BuzzPushKit`) gains a
minimal in-house bech32 codec (bech32 is the checksummed string encoding
npubs use) — checksum-validated, 32-byte keys only, and no new external
dependency. The hex input branch accepts exactly a 64 ASCII hex digit
key before any parsing, so strings that merely parse like hex (for
example a run of `+a` pairs) cannot become a displayed identity; this is
input validation for presentation. Event signature verification is
untouched.

### Related issue

Fixes: N/A. Searched existing issues/PRs for push-notification npub
identity — closest related: none found.

### Testing

At head `3e3f2813b8864b76257ccb50dea3a4b31fa4de0d` (base
`44316ff72f5f7de014c66b01cbf534298a70c249`; 4 files, +321/−4):

- CI `Mobile Swift` lane, at this exact head — all passed: `swift test`
(73 tests, 0 failures), the SwiftPM debug and release builds of
`mobile/ios/BuzzPushKit`, and the unsigned iOS release build.
- Test coverage: npub encoding cross-checked against independent
nostr-rs/NIP-19 vectors; rejection of bad checksums, mixed case, wrong
lengths, invalid alphabet, padding, and non-32-byte payloads; resolver
boundary matrix — hex/npub/invalid sender keys render compact npub or
"Someone" while body, subtitle, sender key, and thread identifier pass
through; named senders keep cached display names.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 10:31:42 -04:00

99 lines
4.5 KiB
Swift

import XCTest
@testable import BuzzPushKit
/// Bech32 codec tests against independently published npub vectors — the
/// NIP-19 spec example key and the nostr crate 0.44 test suite — plus
/// degenerate key material for the padding and payload-length boundaries.
/// Generic BIP-173 conformance is not asserted here: nothing outside the
/// codec calls raw decode, so alphabet, checksum, case, and length
/// rejection are pinned at canonicalNpub/npubBytes, the npub seam Buzz
/// actually uses.
final class Bech32Tests: XCTestCase {
/// Hex public keys with their published npub equivalents.
static let npubVectors: [(hex: String, npub: String)] = [
// nostr-rs 0.44 key test: aa4fc866… ↔ npub14f8usejl…qqh9nsy.
(
"aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4",
"npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy"
),
// The NIP-19 spec's example profile key.
(
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
),
// Degenerate key material still encodes (and exercises 5-bit padding).
(String(repeating: "00", count: 32), "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme"),
]
func testNpubEncodingMatchesKnownVectors() throws {
for vector in Self.npubVectors {
let bytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(vector.hex))
XCTAssertEqual(bytes.count, 32)
XCTAssertEqual(Bech32.npub(from: bytes), vector.npub, "hex: \(vector.hex)")
XCTAssertEqual(Bech32.canonicalNpub(from: vector.hex), vector.npub, "hex: \(vector.hex)")
}
}
func testCanonicalNpubValidatesAndCanonicalizesNpubInputs() throws {
for vector in Self.npubVectors {
let npub = try XCTUnwrap(Bech32.canonicalNpub(from: vector.npub))
XCTAssertEqual(npub, vector.npub, "npub: \(vector.npub)")
XCTAssertEqual(
Bech32.npubBytes(from: npub).map(VerifiedNostrEvent.hex), vector.hex,
"npub: \(vector.npub)")
}
// Uppercase bech32 is valid per BIP-173; canonical output is lowercase.
XCTAssertEqual(
Bech32.canonicalNpub(
from: "NPUB14F8USEJL26TWX0DHUXJH9CAS7KEAV9VR0V8NVTWTRJQX3VYCC76QQH9NSY"),
"npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy")
// Letter case within a hex key stays valid input; digits are caseless,
// so mixed-case letters still canonicalize to the lowercase npub.
let mixedCaseHex = String(
Self.npubVectors[0].hex.enumerated().map {
$0.offset.isMultiple(of: 2) ? $0.element : Character($0.element.uppercased())
})
XCTAssertEqual(Bech32.canonicalNpub(from: mixedCaseHex), Self.npubVectors[0].npub)
}
func testCanonicalNpubRejectsInvalidAndLookalikeKeys() {
let rejected = [
// Junk and empty payloads.
"",
"author-pubkey",
String(repeating: "a", count: 63),
"0",
// Hex that is not a 32-byte key.
String(repeating: "ab", count: 31),
String(repeating: "ab", count: 33),
// Signed radix-16 chunks ("+a"→10, "-0"→0) parse via UInt8(_:radix:)
// but are not literal ASCII hex digits, so never 32-byte keys.
String(repeating: "+a", count: 32),
String(repeating: "+A", count: 32),
String(repeating: "-0", count: 32),
// Npub-shaped payload with a character outside the bech32 alphabet
// ("b" never appears in the charset).
"npub1bqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme",
// Valid npub with the checksum's final character mutated.
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujma",
// Mixed case is never valid bech32.
"Npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy",
// Valid checksum but the wrong payload length for a key.
"npub1qqqqqqqqqqqqqqqqqqqqqqqqqqk7h3rf",
"npub1llllllllllllllllllllllllllllllllllllllllllllllllllll7w6tc2n",
// Valid bech32 under a different human-readable part.
"nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwkhnav",
]
for string in rejected {
XCTAssertNil(Bech32.canonicalNpub(from: string), "expected rejection: \(string)")
XCTAssertNil(Bech32.npubBytes(from: string), "expected rejection: \(string)")
}
}
func testNpubRejectsNon32ByteKeys() {
XCTAssertNil(Bech32.npub(from: [UInt8](repeating: 0, count: 16)))
XCTAssertNil(Bech32.npub(from: [UInt8](repeating: 0xff, count: 33)))
XCTAssertNil(Bech32.npub(from: []))
}
}