mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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>
228 lines
9.1 KiB
Swift
228 lines
9.1 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import ImageIO
|
|
import UniformTypeIdentifiers
|
|
|
|
/// Fetches an avatar with the extension's private HTTP cache and bounded native decoding.
|
|
public final class BuzzNotificationAvatarLoader {
|
|
private let cacheLock = NSLock()
|
|
private var httpCaches: [String: URLCache] = [:]
|
|
private let directory: URL
|
|
private let privateKey: (String) -> String?
|
|
private let isCurrent: (BuzzNotificationCacheScope) -> Bool
|
|
private static let maximumDownloadBytes = 2 * 1024 * 1024
|
|
|
|
public init(
|
|
directory: URL, privateKey: @escaping (String) -> String?,
|
|
isCurrent: @escaping (BuzzNotificationCacheScope) -> Bool
|
|
) {
|
|
self.directory = directory
|
|
self.privateKey = privateKey
|
|
self.isCurrent = isCurrent
|
|
}
|
|
|
|
/// Returns a bounded PNG suitable for INImage. HTTP caching retains the original response.
|
|
public func load(
|
|
source: String, scope: BuzzNotificationCacheScope,
|
|
completion: @escaping (Result<Data, Error>) -> Void
|
|
) {
|
|
guard isCurrent(scope) else {
|
|
completion(.failure(BuzzNotificationCacheError.revoked))
|
|
return
|
|
}
|
|
if source.hasPrefix("data:") {
|
|
do { completion(.success(try Self.decode(Self.inlineData(source)))) } catch {
|
|
completion(.failure(error))
|
|
}
|
|
return
|
|
}
|
|
guard let original = Self.posterURL(source)
|
|
else {
|
|
completion(.failure(BuzzNotificationCacheError.invalidRecord))
|
|
return
|
|
}
|
|
let thumbnail = Self.thumbnailURL(original, scope: scope)
|
|
fetch(thumbnail ?? original, scope: scope) { result in
|
|
if thumbnail != nil, case .failure(BuzzNotificationDownload.Failure.status(404)) = result {
|
|
// Older or externally populated media may not have a generated thumbnail.
|
|
self.fetch(original, scope: scope, completion: completion)
|
|
} else {
|
|
completion(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fetch(
|
|
_ url: URL, scope: BuzzNotificationCacheScope,
|
|
completion: @escaping (Result<Data, Error>) -> Void
|
|
) {
|
|
guard isCurrent(scope) else {
|
|
completion(.failure(BuzzNotificationCacheError.revoked))
|
|
return
|
|
}
|
|
var request = URLRequest(url: url)
|
|
if Self.isRelayMedia(url, scope: scope) {
|
|
guard let key = privateKey(scope.communityID) else {
|
|
completion(.failure(BuzzNotificationCacheError.revoked))
|
|
return
|
|
}
|
|
do {
|
|
request.setValue(
|
|
try Self.authorization(url: url, key: key, account: scope.account),
|
|
forHTTPHeaderField: "Authorization")
|
|
} catch {
|
|
completion(.failure(error))
|
|
return
|
|
}
|
|
}
|
|
let configuration = URLSessionConfiguration.default
|
|
configuration.httpCookieStorage = nil
|
|
configuration.urlCredentialStorage = nil
|
|
configuration.urlCache = httpCache(scope)
|
|
BuzzNotificationDownload.start(
|
|
request: request, configuration: configuration, maximumBytes: Self.maximumDownloadBytes
|
|
) { result in
|
|
guard self.isCurrent(scope) else {
|
|
completion(.failure(BuzzNotificationCacheError.revoked))
|
|
return
|
|
}
|
|
completion(result.flatMap { bytes in Result { try Self.decode(bytes) } })
|
|
}
|
|
}
|
|
|
|
private func httpCache(_ scope: BuzzNotificationCacheScope) -> URLCache {
|
|
cacheLock.lock()
|
|
defer { cacheLock.unlock() }
|
|
if let cache = httpCaches[scope.directoryName] { return cache }
|
|
let cache = URLCache(
|
|
memoryCapacity: 512 * 1024, diskCapacity: 8 * 1024 * 1024,
|
|
directory: directory.appendingPathComponent(scope.directoryName).appendingPathComponent(
|
|
"http"))
|
|
httpCaches[scope.directoryName] = cache
|
|
return cache
|
|
}
|
|
|
|
static func normalizedSource(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.hasPrefix("data:image/svg+xml"), trimmed.utf8.count <= 256 * 1024,
|
|
let comma = trimmed.firstIndex(of: ","),
|
|
[
|
|
"data:image/svg+xml", "data:image/svg+xml;base64", "data:image/svg+xml;utf8",
|
|
"data:image/svg+xml;charset=utf-8",
|
|
].contains(String(trimmed[..<comma]))
|
|
{
|
|
return trimmed
|
|
}
|
|
return BuzzPushPresentationCacheStore.normalizedAvatarURL(trimmed)
|
|
}
|
|
|
|
/// Animated avatars use the sender-selected poster, never the animation URL.
|
|
static func posterURL(_ source: String) -> URL? {
|
|
guard let normalized = BuzzPushPresentationCacheStore.normalizedAvatarURL(source),
|
|
var components = URLComponents(string: normalized)
|
|
else { return nil }
|
|
components.fragment = nil
|
|
return components.url
|
|
}
|
|
|
|
static func thumbnailURL(_ url: URL, scope: BuzzNotificationCacheScope) -> URL? {
|
|
guard isRelayMedia(url, scope: scope) else { return nil }
|
|
let parts = url.lastPathComponent.split(separator: ".")
|
|
guard let hash = parts.first, hash.count == 64,
|
|
VerifiedNostrEvent.hexBytes(String(hash))?.count == 32,
|
|
parts.count <= 2
|
|
else { return nil }
|
|
return url.deletingLastPathComponent().appendingPathComponent("\(hash).thumb.jpg")
|
|
}
|
|
|
|
private static func isRelayMedia(_ url: URL, scope: BuzzNotificationCacheScope) -> Bool {
|
|
guard let origin = URL(string: scope.relayOrigin) else { return false }
|
|
func effectivePort(_ value: URL) -> Int? {
|
|
value.port ?? (value.scheme?.lowercased() == "https" ? 443 : 80)
|
|
}
|
|
return url.scheme?.lowercased() == origin.scheme?.lowercased()
|
|
&& url.host?.lowercased() == origin.host?.lowercased()
|
|
&& effectivePort(url) == effectivePort(origin)
|
|
&& url.path.hasPrefix("/media/")
|
|
}
|
|
|
|
private static func inlineData(_ source: String) throws -> Data {
|
|
guard normalizedSource(source) != nil, let comma = source.firstIndex(of: ",") else {
|
|
throw BuzzNotificationCacheError.invalidRecord
|
|
}
|
|
let header = source[..<comma]
|
|
let payload = String(source[source.index(after: comma)...])
|
|
let bytes: Data?
|
|
if header.hasSuffix(";base64") {
|
|
bytes = Data(base64Encoded: payload)
|
|
} else {
|
|
bytes = payload.removingPercentEncoding?.data(using: .utf8)
|
|
}
|
|
guard let bytes, bytes.count <= maximumDownloadBytes else {
|
|
throw BuzzNotificationCacheError.invalidRecord
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
static func decode(_ data: Data) throws -> Data {
|
|
if let text = String(data: data.prefix(256), encoding: .utf8),
|
|
text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("<")
|
|
{
|
|
return try BuzzNotificationSVG.decode(data)
|
|
}
|
|
guard !data.isEmpty, data.count <= maximumDownloadBytes,
|
|
let imageSource = CGImageSourceCreateWithData(
|
|
data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
|
|
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
|
let width = properties[kCGImagePropertyPixelWidth] as? Int,
|
|
let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
|
width > 0, height > 0, width <= 16384, height <= 16384,
|
|
Int64(width) * Int64(height) <= 64_000_000,
|
|
let image = CGImageSourceCreateThumbnailAtIndex(
|
|
imageSource, 0,
|
|
[
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: 320,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
] as CFDictionary)
|
|
else { throw BuzzNotificationCacheError.invalidRecord }
|
|
let output = NSMutableData()
|
|
guard
|
|
let destination = CGImageDestinationCreateWithData(
|
|
output, UTType.png.identifier as CFString, 1, nil)
|
|
else { throw BuzzNotificationCacheError.invalidRecord }
|
|
CGImageDestinationAddImage(destination, image, nil)
|
|
guard CGImageDestinationFinalize(destination),
|
|
output.length <= BuzzNotificationCache.maximumAvatarBytes
|
|
else { throw BuzzNotificationCacheError.oversizedRecord }
|
|
return output as Data
|
|
}
|
|
|
|
static func authorization(url: URL, key: String, account: String) throws -> String {
|
|
guard let bytes = VerifiedNostrEvent.hexBytes(key), bytes.count == 32 else {
|
|
throw NostrHTTPAuthError.invalidHex
|
|
}
|
|
let signingKey = bytes
|
|
let pubkey = VerifiedNostrEvent.hex(try NostrSchnorr.publicKey(secret: signingKey))
|
|
guard pubkey == account else { throw BuzzNotificationCacheError.revoked }
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
// URLComponents preserves the brackets required around IPv6 authorities.
|
|
let host = URLComponents(url: url, resolvingAgainstBaseURL: false)?.host ?? ""
|
|
let authority = host + (url.port.map { ":\($0)" } ?? "")
|
|
let tags = [["t", "get"], ["expiration", String(timestamp + 60)], ["server", authority]]
|
|
let content = "Get buzz-media"
|
|
let serialization = try VerifiedNostrEvent.canonicalSerialization(
|
|
pubkey: pubkey, createdAt: timestamp, kind: 24242, tags: tags, content: content)
|
|
let digest = Array(SHA256.hash(data: serialization))
|
|
let id = VerifiedNostrEvent.hex(digest)
|
|
let signature = try NostrSchnorr.sign(message: digest, secret: signingKey)
|
|
let event = VerifiedNostrEvent(
|
|
id: id, pubkey: pubkey, createdAt: timestamp,
|
|
kind: 24242, tags: tags, content: content,
|
|
sig: VerifiedNostrEvent.hex(signature))
|
|
return "Nostr " + (try JSONEncoder().encode(event)).base64EncodedString()
|
|
}
|
|
}
|