Files
buzz/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationCacheTests.swift
Tom Brow 73962435b6 Use upstream C secp256k1 for native Nostr signatures (#8398)
Replace the Swift secp256k1 wrapper with a small native adapter over
Bitcoin Core libsecp256k1. This is an alternative to the wrapper upgrade
in [#8381](https://github.com/block/buzz/pull/8381), not an additional
change to merge alongside it.

The adapter covers the three Nostr operations Buzz needs: x-only
public-key derivation, BIP-340 signing, and signature verification. It
compiles pinned, unmodified upstream C source through SwiftPM. There is
no build plugin, plugin approval script, or underscored Swift import.
The app and notification extension use an explicit C module map and
header search path.

This trades the third-party Swift wrapper for a Buzz-owned adapter and
responsibility for updating the vendored C source. Upstream v0.8.0
source, its license, and update provenance are included. The release
Xcode pin remains separate in
[buzz-releases#110](https://github.com/squareup/buzz-releases/pull/110).

### Validation

- Native package tests passed on Xcode 27.0 and 26.5: 96 XCTest tests
and 65 Swift Testing tests per toolchain.
- Added tests cover the official BIP-340 vectors for 32-byte Nostr
messages, invalid secret keys, and malformed input lengths.
- Full unsigned Release app and notification extension build passed on
exact Xcode 27.0.
- Vendored files were compared byte-for-byte with upstream v0.8.0.
- Full local `just ci` passed.
- [Hosted full unsigned iOS Release
build](https://github.com/block/buzz/actions/runs/38924020583/job/117051237094)
passed. [Hosted Native Swift
validation](https://github.com/block/buzz/actions/runs/38924020583/job/117051237153)
passed, including the complete simulator build and native controls/push
navigation tests.
- Codex code review and security review completed with no findings.

### Before merge

- [x] Tom selected the direct-C approach with vendored upstream source;
[the wrapper alternative](https://github.com/block/buzz/pull/8381) is
closed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 09:26:55 -07:00

336 lines
15 KiB
Swift

import CryptoKit
import Foundation
import Testing
@testable import BuzzPushKit
@Suite("Extension-owned notification cache")
struct BuzzNotificationCacheTests {
private let key = String(repeating: "0", count: 63) + "1"
private func scope(_ session: String = "login-one") throws -> BuzzNotificationCacheScope {
try #require(
BuzzNotificationCacheScope(
community: PushLeaseCommunity(
id: "community", name: "Test", relayUrl: "wss://relay.example",
pubkey: "account", policies: [], notificationSessionID: session)))
}
private func directory() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
@Test("Replacement retains the previous avatar; removal rejects a late replacement")
func replacementAndRemoval() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
let first = try signedEvent(
privateKey: key, createdAt: 1, kind: 0,
content: #"{"name":"A","picture":"https://image.example/a.png"}"#)
try cache.save(event: first, scope: scope)
let firstRequest = try #require(
try cache.beginAvatarRequest(
profileID: first.id, pubkey: first.pubkey, scope: scope))
let bytes = Data([1, 2, 3])
try cache.saveAvatar(
bytes, source: "https://image.example/a.png",
profileID: first.id, requestID: firstRequest, pubkey: first.pubkey, scope: scope)
let replacement = try signedEvent(
privateKey: key, createdAt: 2, kind: 0,
content: #"{"name":"A","picture":"https://image.example/b.png"}"#)
try cache.save(event: replacement, scope: scope)
#expect(try cache.avatar(scope: scope, pubkey: first.pubkey)?.data == bytes)
let replacementRequest = try #require(
try cache.beginAvatarRequest(
profileID: replacement.id, pubkey: first.pubkey, scope: scope))
let removal = try signedEvent(privateKey: key, createdAt: 3, kind: 0, content: #"{"name":"A"}"#)
try cache.save(event: removal, scope: scope)
try cache.saveAvatar(
bytes, source: "https://image.example/b.png",
profileID: replacement.id, requestID: replacementRequest, pubkey: first.pubkey, scope: scope)
#expect(try cache.avatar(scope: scope, pubkey: first.pubkey) == nil)
try cache.save(event: first, scope: scope)
#expect(try cache.metadata(scope: scope, kind: 0, author: first.pubkey)?.event.id == removal.id)
}
@Test("Two cache objects preserve each other's records and data survives reopening")
func multipleInstances() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let first = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
let second = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
let profile = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
let channel = try signedEvent(
privateKey: key, kind: 39000, tags: [["d", "room"], ["name", "General"]])
try first.save(event: profile, scope: scope)
try second.save(event: channel, scope: scope)
#expect(
try first.metadata(scope: scope, kind: 39000, author: channel.pubkey, identifier: "room")?
.event == channel)
#expect(try second.metadata(scope: scope, kind: 0, author: profile.pubkey)?.event == profile)
}
@Test("Concurrent notification writers preserve every independent record")
func concurrentWriters() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let events = try (0..<24).map { index in
try signedEvent(
privateKey: key, kind: 39000,
tags: [["d", "room-\(index)"], ["name", "Room \(index)"]])
}
let errorLock = NSLock()
var failures: [String] = []
DispatchQueue.concurrentPerform(iterations: events.count) { index in
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
do {
try cache.save(event: events[index], scope: scope)
let record = try cache.metadata(
scope: scope, kind: 39000,
author: events[index].pubkey, identifier: "room-\(index)")
if record?.event != events[index] {
errorLock.lock()
failures.append("Missing record \(index)")
errorLock.unlock()
}
} catch {
errorLock.lock()
failures.append(String(describing: error))
errorLock.unlock()
}
}
#expect(failures.isEmpty)
let reopened = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
for (index, event) in events.enumerated() {
#expect(
try reopened.metadata(
scope: scope, kind: 39000,
author: event.pubkey, identifier: "room-\(index)")?.event == event)
}
}
@Test("A failed batch cannot publish only its channel metadata")
func atomicBatchFailure() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
let channel = try signedEvent(
privateKey: key, createdAt: 1, kind: 39000, tags: [["d", "room"]])
let members = try signedEvent(
privateKey: key, createdAt: 1, kind: 39002, tags: [["d", "room"]])
try cache.save(events: [channel, members], scope: scope)
let newer = try signedEvent(privateKey: key, createdAt: 2, kind: 39000, tags: [["d", "room"]])
let invalid = try signedEvent(privateKey: key, createdAt: 2, kind: 9)
#expect(throws: BuzzNotificationCacheError.self) {
try cache.save(events: [newer, invalid], scope: scope)
}
let snapshot = try cache.presentation(
scope: scope, sender: channel.pubkey,
channelID: "room", authority: channel.pubkey)
#expect(snapshot.channel?.event == channel)
#expect(snapshot.membership?.event == members)
}
@Test("Partial channel refreshes cannot reuse an old counterpart")
func partialChannelRefresh() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
for refreshedKind in [39000, 39002] {
let old = try [39000, 39002].map {
try signedEvent(privateKey: key, createdAt: 1, kind: $0, tags: [["d", "room-\(refreshedKind)"]])
}
try cache.saveChannel(channel: old[0], membership: old[1], scope: scope)
let newer = try signedEvent(
privateKey: key, createdAt: 2, kind: refreshedKind,
tags: [["d", "room-\(refreshedKind)"]])
try cache.saveChannel(
channel: refreshedKind == 39000 ? newer : nil,
membership: refreshedKind == 39002 ? newer : nil, scope: scope)
let snapshot = try cache.presentation(
scope: scope, sender: newer.pubkey,
channelID: "room-\(refreshedKind)", authority: newer.pubkey)
#expect(snapshot.channel?.event == (refreshedKind == 39000 ? newer : nil))
#expect(snapshot.membership?.event == (refreshedKind == 39002 ? newer : nil))
}
}
@Test("Older channel responses cannot invalidate or partly replace a newer pair")
func olderChannelRefresh() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
let current = try [39000, 39002].map {
try signedEvent(privateKey: key, createdAt: 2, kind: $0, tags: [["d", "room"]])
}
try cache.saveChannel(channel: current[0], membership: current[1], scope: scope)
let older = try signedEvent(privateKey: key, createdAt: 1, kind: 39000, tags: [["d", "room"]])
let newer = try signedEvent(privateKey: key, createdAt: 3, kind: 39002, tags: [["d", "room"]])
try cache.saveChannel(channel: older, membership: nil, scope: scope)
try cache.saveChannel(channel: older, membership: newer, scope: scope)
try cache.saveChannel(channel: nil, membership: nil, scope: scope)
let snapshot = try cache.presentation(
scope: scope, sender: current[0].pubkey, channelID: "room", authority: current[0].pubkey)
#expect(snapshot.channel?.event == current[0])
#expect(snapshot.membership?.event == current[1])
}
@Test("Concurrent channel readers see one complete refresh")
func atomicBatchReaders() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let pairs = try (1...20).map { version in
try [39000, 39002].map { kind in
try signedEvent(privateKey: key, createdAt: version, kind: kind, tags: [["d", "room"]])
}
}
let errorsLock = NSLock()
var errors: [String] = []
DispatchQueue.concurrentPerform(iterations: pairs.count) { index in
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
do {
try cache.save(events: pairs[index], scope: scope)
let snapshot = try cache.presentation(
scope: scope, sender: pairs[index][0].pubkey,
channelID: "room", authority: pairs[index][0].pubkey)
if snapshot.channel?.event.createdAt != snapshot.membership?.event.createdAt {
errorsLock.lock()
errors.append("Mixed channel refresh")
errorsLock.unlock()
}
} catch {
errorsLock.lock()
errors.append(String(describing: error))
errorsLock.unlock()
}
}
#expect(errors.isEmpty)
}
@Test("Reads do not extend freshness; a successful recheck does")
func freshness() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
let first = BuzzNotificationCache(
directory: root,
now: { Date(timeIntervalSince1970: 100) }, isCurrent: { $0 == scope })
try first.save(event: event, scope: scope)
let cached = try #require(try first.metadata(scope: scope, kind: 0, author: event.pubkey))
#expect(cached.isFresh(at: Date(timeIntervalSince1970: 3699)))
#expect(!cached.isFresh(at: Date(timeIntervalSince1970: 3700)))
let next = BuzzNotificationCache(
directory: root,
now: { Date(timeIntervalSince1970: 4000) }, isCurrent: { $0 == scope })
#expect(
try next.metadata(scope: scope, kind: 0, author: event.pubkey)?.checkedAt == cached.checkedAt)
try next.save(event: event, scope: scope)
#expect(
try next.metadata(scope: scope, kind: 0, author: event.pubkey)?.checkedAt
== Date(timeIntervalSince1970: 4000))
}
@Test("Removed account is inaccessible and a new sign-in cannot reuse its cache")
func removedAccount() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let old = try scope()
let new = try scope("login-two")
let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
try BuzzNotificationCache(directory: root, isCurrent: { $0 == old }).save(
event: event, scope: old)
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == new })
#expect(throws: BuzzNotificationCacheError.self) {
try cache.metadata(scope: old, kind: 0, author: event.pubkey)
}
#expect(throws: BuzzNotificationCacheError.self) { try cache.save(event: event, scope: old) }
#expect(try cache.metadata(scope: new, kind: 0, author: event.pubkey) == nil)
try cache.removeRevokedScopes(keeping: [new])
#expect(
!FileManager.default.fileExists(atPath: root.appendingPathComponent(old.directoryName).path))
}
@Test("Incomplete cache file is reported, removed, and can be fetched again")
func interruptedWriteRecovery() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let scope = try scope()
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
try cache.save(event: event, scope: scope)
let file = root.appendingPathComponent(scope.directoryName).appendingPathComponent(
"metadata.json")
try Data("{broken".utf8).write(to: file)
#expect(throws: BuzzNotificationCacheError.self) {
try cache.metadata(scope: scope, kind: 0, author: event.pubkey)
}
try cache.save(event: event, scope: scope)
#expect(try cache.metadata(scope: scope, kind: 0, author: event.pubkey)?.event == event)
}
@Test("Bootstrap keeps a session across updates but renews it after removal")
func bootstrapSession() throws {
let root = try directory()
defer { try? FileManager.default.removeItem(at: root) }
let bootstrap = BuzzPushPresentationCacheStore(containerURL: root)
let community = PushLeaseCommunity(
id: "community", name: "Test", relayUrl: "wss://relay.example",
pubkey: "account", policies: [])
func session() throws -> String? {
let data = try Data(
contentsOf: root.appendingPathComponent(BuzzPushPresentationCacheStore.fileName))
return BuzzPushPresentationCacheSnapshot.decode(data).communities.first?.notificationSessionID
}
try bootstrap.replaceCommunities([community])
let first = try #require(try session())
try bootstrap.replaceCommunities([community])
#expect(try session() == first)
try bootstrap.replaceCommunities([])
try bootstrap.replaceCommunities([community])
#expect(try session() != first)
}
private func signedEvent(
privateKey: String,
createdAt: Int = 1_700_000_000,
kind: Int,
tags: [[String]] = [],
content: String = ""
) throws -> VerifiedNostrEvent {
let privateKeyBytes = try #require(VerifiedNostrEvent.hexBytes(privateKey))
let key = privateKeyBytes
let pubkey = VerifiedNostrEvent.hex(try NostrSchnorr.publicKey(secret: key))
let serialization = try VerifiedNostrEvent.canonicalSerialization(
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
tags: tags,
content: content
)
let digest = Array(SHA256.hash(data: serialization))
let message = digest
let randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32)
let signature = try NostrSchnorr.sign(message: message, secret: key, auxiliaryRandomness: randomness)
return VerifiedNostrEvent(
id: VerifiedNostrEvent.hex(digest),
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
tags: tags,
content: content,
sig: VerifiedNostrEvent.hex(signature)
)
}
}