mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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>
133 lines
4.9 KiB
Swift
133 lines
4.9 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
|
|
public enum NostrHTTPAuthError: Error, Equatable {
|
|
case invalidHex
|
|
case signingFailed
|
|
}
|
|
|
|
public struct VerifiedNostrEvent: Codable, Equatable, Sendable {
|
|
public let id: String
|
|
public let pubkey: String
|
|
public let createdAt: Int
|
|
public let kind: Int
|
|
public let tags: [[String]]
|
|
public let content: String
|
|
public let sig: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, pubkey, kind, tags, content, sig
|
|
case createdAt = "created_at"
|
|
}
|
|
|
|
public init(
|
|
id: String, pubkey: String, createdAt: Int, kind: Int,
|
|
tags: [[String]], content: String, sig: String
|
|
) {
|
|
self.id = id
|
|
self.pubkey = pubkey
|
|
self.createdAt = createdAt
|
|
self.kind = kind
|
|
self.tags = tags
|
|
self.content = content
|
|
self.sig = sig
|
|
}
|
|
|
|
public func hasValidIDAndSignature() -> Bool {
|
|
guard let idBytes = Self.hexBytes(id), idBytes.count == 32,
|
|
let pubkeyBytes = Self.hexBytes(pubkey), pubkeyBytes.count == 32,
|
|
let signatureBytes = Self.hexBytes(sig), signatureBytes.count == 64,
|
|
let serialized = try? Self.canonicalSerialization(
|
|
pubkey: pubkey.lowercased(), createdAt: createdAt, kind: kind,
|
|
tags: tags, content: content
|
|
)
|
|
else { return false }
|
|
let digest = Array(SHA256.hash(data: serialized))
|
|
guard digest == idBytes else { return false }
|
|
return NostrSchnorr.verify(signature: signatureBytes, message: digest, publicKey: pubkeyBytes)
|
|
}
|
|
|
|
static func canonicalSerialization(
|
|
pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String
|
|
) throws -> Data {
|
|
try JSONSerialization.data(
|
|
withJSONObject: [0, pubkey, createdAt, kind, tags, content],
|
|
options: [.withoutEscapingSlashes]
|
|
)
|
|
}
|
|
|
|
static func hexBytes(_ value: String) -> [UInt8]? {
|
|
guard value.count.isMultiple(of: 2) else { return nil }
|
|
var result: [UInt8] = []
|
|
result.reserveCapacity(value.count / 2)
|
|
var index = value.startIndex
|
|
while index < value.endIndex {
|
|
let end = value.index(index, offsetBy: 2)
|
|
guard let byte = UInt8(value[index..<end], radix: 16) else { return nil }
|
|
result.append(byte)
|
|
index = end
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func hex(_ bytes: some Sequence<UInt8>) -> String {
|
|
bytes.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|
|
|
|
public enum NostrHTTPAuth {
|
|
public static func authorizationHeader(
|
|
url: URL,
|
|
method: String,
|
|
body: Data,
|
|
privateKeyHex: String,
|
|
createdAt: Int = Int(Date().timeIntervalSince1970),
|
|
auxiliaryRandomness: [UInt8]? = nil
|
|
) throws -> String {
|
|
guard let privateKeyBytes = VerifiedNostrEvent.hexBytes(privateKeyHex),
|
|
privateKeyBytes.count == 32
|
|
else { throw NostrHTTPAuthError.invalidHex }
|
|
do {
|
|
let privateKey = privateKeyBytes
|
|
let pubkey = VerifiedNostrEvent.hex(try NostrSchnorr.publicKey(secret: privateKey))
|
|
let payload = VerifiedNostrEvent.hex(SHA256.hash(data: body))
|
|
let tags = [
|
|
["u", url.absoluteString],
|
|
["method", method.uppercased()],
|
|
["payload", payload],
|
|
// Separate requests in the same second must not reuse an event ID.
|
|
["nonce", UUID().uuidString.lowercased()],
|
|
]
|
|
let serialized = try VerifiedNostrEvent.canonicalSerialization(
|
|
pubkey: pubkey, createdAt: createdAt, kind: 27235,
|
|
tags: tags, content: ""
|
|
)
|
|
let digest = Array(SHA256.hash(data: serialized))
|
|
let message = digest
|
|
let signature: [UInt8]
|
|
if let randomness = auxiliaryRandomness {
|
|
guard randomness.count == 32 else { throw NostrHTTPAuthError.signingFailed }
|
|
signature = try NostrSchnorr.sign(message: message, secret: privateKey, auxiliaryRandomness: randomness)
|
|
} else {
|
|
signature = try NostrSchnorr.sign(message: message, secret: privateKey)
|
|
}
|
|
let event = VerifiedNostrEvent(
|
|
id: VerifiedNostrEvent.hex(digest),
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: 27235,
|
|
tags: tags,
|
|
content: "",
|
|
sig: VerifiedNostrEvent.hex(signature)
|
|
)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.withoutEscapingSlashes]
|
|
return "Nostr " + (try encoder.encode(event)).base64EncodedString()
|
|
} catch let error as NostrHTTPAuthError {
|
|
throw error
|
|
} catch {
|
|
throw NostrHTTPAuthError.signingFailed
|
|
}
|
|
}
|
|
}
|