Files
buzz/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.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

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
}
}
}