mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
feat(mobile/ios): publish development push leases
Co-authored-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Signed-off-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz>
This commit is contained in:
parent
7a3eae89a3
commit
0f069252a2
@@ -13,30 +13,42 @@ import Foundation
|
||||
public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable {
|
||||
public let relayOrigin: String
|
||||
public let relayPubkey: String
|
||||
public let installationId: String
|
||||
public let endpointGrant: String
|
||||
public let endpointHash: String
|
||||
public let appProfile: String
|
||||
public let endpointEpoch: Int64
|
||||
public let generation: Int64
|
||||
public let publishedGeneration: Int64?
|
||||
public let expiresAt: Int64
|
||||
|
||||
public init(
|
||||
relayOrigin: String,
|
||||
relayPubkey: String,
|
||||
installationId: String,
|
||||
endpointGrant: String,
|
||||
endpointHash: String,
|
||||
appProfile: String,
|
||||
endpointEpoch: Int64,
|
||||
generation: Int64,
|
||||
publishedGeneration: Int64?,
|
||||
expiresAt: Int64
|
||||
) {
|
||||
precondition(generation > 0, "Endpoint grant generation must be positive")
|
||||
precondition(
|
||||
publishedGeneration == nil
|
||||
|| (publishedGeneration! > 0 && publishedGeneration! <= generation),
|
||||
"Published push lease generation must be positive and no greater than the grant generation"
|
||||
)
|
||||
self.relayOrigin = relayOrigin
|
||||
self.relayPubkey = relayPubkey
|
||||
self.installationId = installationId
|
||||
self.endpointGrant = endpointGrant
|
||||
self.endpointHash = endpointHash
|
||||
self.appProfile = appProfile
|
||||
self.endpointEpoch = endpointEpoch
|
||||
self.generation = generation
|
||||
self.publishedGeneration = publishedGeneration
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
}
|
||||
@@ -46,6 +58,7 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable {
|
||||
public protocol BuzzPushEndpointGrantStore {
|
||||
func records() throws -> [BuzzPushEndpointGrantRecord]
|
||||
func save(_ record: BuzzPushEndpointGrantRecord) throws
|
||||
func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@@ -56,6 +69,7 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
case invalidResponse(route: String)
|
||||
case unexpectedStatus(route: String, expected: Int, actual: Int, body: String)
|
||||
case randomGenerationFailed(Int32)
|
||||
case generationExhausted
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -71,6 +85,8 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)"
|
||||
case .randomGenerationFailed(let status):
|
||||
return "Secure random generation failed with status \(status)."
|
||||
case .generationExhausted:
|
||||
return "The development push grant generation cannot advance further."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +108,11 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
|
||||
let randomBytes: () throws -> Data
|
||||
|
||||
init(randomBytes: @escaping () throws -> Data = BuzzDevAppAttestProvider.secureRandomBytes) {
|
||||
init(
|
||||
randomBytes: @escaping () throws -> Data = {
|
||||
try BuzzDevAppAttestProvider.secureRandomBytes()
|
||||
}
|
||||
) {
|
||||
self.randomBytes = randomBytes
|
||||
}
|
||||
|
||||
@@ -117,8 +137,8 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
return Self.assertionBytes.base64EncodedString()
|
||||
}
|
||||
|
||||
private static func secureRandomBytes() throws -> Data {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
fileprivate static func secureRandomBytes(count: Int = 32) throws -> Data {
|
||||
var bytes = [UInt8](repeating: 0, count: count)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
throw BuzzDevPushEnrollmentError.randomGenerationFailed(status)
|
||||
@@ -132,7 +152,6 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
public final class BuzzDevPushEnrollmentDriver {
|
||||
public static let appProfile = "buzz-ios-sandbox"
|
||||
public static let endpointEpoch: Int64 = 1
|
||||
public static let generation: Int64 = 1
|
||||
|
||||
private let gatewayBaseURL: URL
|
||||
private let store: BuzzPushEndpointGrantStore
|
||||
@@ -140,6 +159,7 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
private let appAttest: BuzzDevAppAttesting
|
||||
private let now: () -> Date
|
||||
private let lifetimeSeconds: Int64
|
||||
private let installationIdBytes: () throws -> Data
|
||||
|
||||
public convenience init(
|
||||
gatewayBaseURL: URL,
|
||||
@@ -152,7 +172,8 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
session: session,
|
||||
appAttest: BuzzDevAppAttestProvider(),
|
||||
now: Date.init,
|
||||
lifetimeSeconds: 2_592_000
|
||||
lifetimeSeconds: 2_592_000,
|
||||
installationIdBytes: { try BuzzDevAppAttestProvider.secureRandomBytes(count: 16) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,7 +183,10 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
session: URLSession,
|
||||
appAttest: BuzzDevAppAttesting,
|
||||
now: @escaping () -> Date,
|
||||
lifetimeSeconds: Int64
|
||||
lifetimeSeconds: Int64,
|
||||
installationIdBytes: @escaping () throws -> Data = {
|
||||
try BuzzDevAppAttestProvider.secureRandomBytes(count: 16)
|
||||
}
|
||||
) throws {
|
||||
guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else {
|
||||
throw BuzzDevPushEnrollmentError.invalidGatewayURL
|
||||
@@ -173,6 +197,7 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
self.appAttest = appAttest
|
||||
self.now = now
|
||||
self.lifetimeSeconds = lifetimeSeconds
|
||||
self.installationIdBytes = installationIdBytes
|
||||
}
|
||||
|
||||
public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] {
|
||||
@@ -192,17 +217,28 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken)))
|
||||
let nowSeconds = Int64(now().timeIntervalSince1970)
|
||||
|
||||
if let current = try store.records().first(where: {
|
||||
$0.relayOrigin == relayOrigin.text
|
||||
&& $0.relayPubkey == relayPubkey
|
||||
&& $0.endpointHash == endpointHash
|
||||
&& $0.appProfile == Self.appProfile
|
||||
&& $0.endpointEpoch == Self.endpointEpoch
|
||||
&& $0.generation == Self.generation
|
||||
&& $0.expiresAt > nowSeconds + 300
|
||||
}) {
|
||||
let storedRecords = try store.records()
|
||||
let storedForOrigin = storedRecords.first {
|
||||
$0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile
|
||||
}
|
||||
if let current = storedForOrigin,
|
||||
current.relayPubkey == relayPubkey,
|
||||
current.endpointHash == endpointHash,
|
||||
current.endpointEpoch == Self.endpointEpoch,
|
||||
current.expiresAt > nowSeconds + 300
|
||||
{
|
||||
return current
|
||||
}
|
||||
let generation: Int64
|
||||
if let storedForOrigin {
|
||||
let (next, overflow) = storedForOrigin.generation.addingReportingOverflow(1)
|
||||
guard !overflow, next > 0 else {
|
||||
throw BuzzDevPushEnrollmentError.generationExhausted
|
||||
}
|
||||
generation = next
|
||||
} else {
|
||||
generation = 1
|
||||
}
|
||||
|
||||
let (expiresAt, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds)
|
||||
guard !expiresOverflow else {
|
||||
@@ -240,7 +276,7 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
challenge: delegationChallenge.value,
|
||||
installationHandle: installation,
|
||||
endpointEpoch: Self.endpointEpoch,
|
||||
generation: Self.generation,
|
||||
generation: generation,
|
||||
relayPubkey: relayPubkey,
|
||||
notBefore: nowSeconds,
|
||||
expiresAt: expiresAt
|
||||
@@ -250,19 +286,35 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
challenge: delegationChallenge,
|
||||
installationHandle: installation,
|
||||
relayPubkey: relayPubkey,
|
||||
generation: generation,
|
||||
notBefore: nowSeconds,
|
||||
expiresAt: expiresAt,
|
||||
assertion: assertion
|
||||
)
|
||||
|
||||
let installationId: String
|
||||
if let storedForOrigin {
|
||||
installationId = storedForOrigin.installationId
|
||||
} else {
|
||||
let installationBytes = try installationIdBytes()
|
||||
precondition(
|
||||
installationBytes.count == 16,
|
||||
"NIP-PL installation identity entropy must be exactly 16 bytes"
|
||||
)
|
||||
// NIP-PL:76 also requires a fresh value on reinstall. Keychain survival can
|
||||
// retain this value across reinstall. Reinstall detection is intentionally deferred.
|
||||
installationId = Self.lowercaseHex(installationBytes)
|
||||
}
|
||||
let record = BuzzPushEndpointGrantRecord(
|
||||
relayOrigin: relayOrigin.text,
|
||||
relayPubkey: relayPubkey,
|
||||
installationId: installationId,
|
||||
endpointGrant: endpointGrant,
|
||||
endpointHash: endpointHash,
|
||||
appProfile: Self.appProfile,
|
||||
endpointEpoch: Self.endpointEpoch,
|
||||
generation: Self.generation,
|
||||
generation: generation,
|
||||
publishedGeneration: nil,
|
||||
expiresAt: expiresAt
|
||||
)
|
||||
try store.save(record)
|
||||
@@ -320,6 +372,7 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
challenge: Challenge,
|
||||
installationHandle: UUID,
|
||||
relayPubkey: String,
|
||||
generation: Int64,
|
||||
notBefore: Int64,
|
||||
expiresAt: Int64,
|
||||
assertion: String
|
||||
@@ -333,7 +386,7 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
challenge: challenge.value,
|
||||
installationHandle: installationHandle.uuidString.lowercased(),
|
||||
endpointEpoch: Self.endpointEpoch,
|
||||
generation: Self.generation,
|
||||
generation: generation,
|
||||
relayPubkey: relayPubkey,
|
||||
notBefore: notBefore,
|
||||
expiresAt: expiresAt,
|
||||
@@ -434,7 +487,8 @@ public protocol BuzzPushEndpointGrantStore {
|
||||
}
|
||||
var relayComponents = components
|
||||
relayComponents.scheme = url.scheme
|
||||
guard let relayText = relayComponents.url?.absoluteString else {
|
||||
relayComponents.path = ""
|
||||
guard let relayText = relayComponents.string else {
|
||||
throw BuzzDevPushEnrollmentError.invalidRelayURL
|
||||
}
|
||||
return (httpURL, relayText)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
private static let firstChallengeId = "11111111-1111-4111-8111-111111111111"
|
||||
private static let secondChallengeId = "33333333-3333-4333-8333-333333333333"
|
||||
private static let installationHandle = "22222222-2222-4222-8222-222222222222"
|
||||
private static let installationId = "000102030405060708090a0b0c0d0e0f"
|
||||
private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||
private static let now: Int64 = 1_752_620_000
|
||||
private static let expiresAt: Int64 = 1_752_624_000
|
||||
@@ -118,6 +119,7 @@
|
||||
)
|
||||
|
||||
XCTAssertEqual(appAttest.clientData.count, 2)
|
||||
XCTAssertEqual(record.relayOrigin, "wss://relay.example")
|
||||
try assertMatchesVector(
|
||||
"enroll",
|
||||
actual: appAttest.clientData[0],
|
||||
@@ -143,19 +145,75 @@
|
||||
XCTAssertEqual(
|
||||
record,
|
||||
BuzzPushEndpointGrantRecord(
|
||||
relayOrigin: "wss://relay.example/",
|
||||
relayOrigin: "wss://relay.example",
|
||||
relayPubkey: Self.relayPubkey,
|
||||
installationId: Self.installationId,
|
||||
endpointGrant: "opaque-grant",
|
||||
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
||||
appProfile: "buzz-ios-sandbox",
|
||||
endpointEpoch: 1,
|
||||
generation: 1,
|
||||
publishedGeneration: nil,
|
||||
expiresAt: Self.expiresAt
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(store.saved, [record])
|
||||
}
|
||||
|
||||
func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws {
|
||||
let relayURL = URL(string: "wss://relay.example:8443/")!
|
||||
let store = MemoryGrantStore()
|
||||
let driver = try makeDriver(store: store, appAttest: RecordingAppAttest())
|
||||
var challengeCount = 0
|
||||
URLProtocolStub.handler = { request in
|
||||
switch (request.httpMethod, request.url?.absoluteString) {
|
||||
case ("GET", "https://relay.example:8443/"):
|
||||
return Self.response(
|
||||
request,
|
||||
status: 200,
|
||||
json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]]
|
||||
)
|
||||
case ("POST", "http://push.example/v1/installations/challenges"):
|
||||
challengeCount += 1
|
||||
return Self.response(
|
||||
request,
|
||||
status: 200,
|
||||
json: [
|
||||
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
|
||||
"challenge": Self.challenge,
|
||||
"expires_at": Self.now + 300,
|
||||
]
|
||||
)
|
||||
case ("POST", "http://push.example/v1/installations"):
|
||||
return Self.response(
|
||||
request,
|
||||
status: 201,
|
||||
json: [
|
||||
"installation_handle": Self.installationHandle,
|
||||
"endpoint_epoch": 1,
|
||||
"expires_at": Self.expiresAt,
|
||||
]
|
||||
)
|
||||
case ("POST", "http://push.example/v1/delegations"):
|
||||
return Self.response(
|
||||
request,
|
||||
status: 201,
|
||||
json: ["endpoint_grant": "opaque-grant"]
|
||||
)
|
||||
default:
|
||||
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
|
||||
return Self.response(request, status: 500, json: [:])
|
||||
}
|
||||
}
|
||||
|
||||
let record = try await driver.enroll(
|
||||
deviceToken: Data((1...32).map(UInt8.init)),
|
||||
relayURL: relayURL
|
||||
)
|
||||
|
||||
XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443")
|
||||
}
|
||||
|
||||
func testDevelopmentAttestationMatchesGatewayBypassShape() throws {
|
||||
let entropy = Data(repeating: 0xAB, count: 32)
|
||||
let provider = BuzzDevAppAttestProvider(randomBytes: { entropy })
|
||||
@@ -177,13 +235,15 @@
|
||||
|
||||
func testReusesPersistedUnexpiredGrant() async throws {
|
||||
let existing = BuzzPushEndpointGrantRecord(
|
||||
relayOrigin: "wss://relay.example/",
|
||||
relayOrigin: "wss://relay.example",
|
||||
relayPubkey: Self.relayPubkey,
|
||||
installationId: Self.installationId,
|
||||
endpointGrant: "existing-grant",
|
||||
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
||||
appProfile: "buzz-ios-sandbox",
|
||||
endpointEpoch: 1,
|
||||
generation: 1,
|
||||
publishedGeneration: 1,
|
||||
expiresAt: Self.expiresAt
|
||||
)
|
||||
let store = MemoryGrantStore(records: [existing])
|
||||
@@ -206,10 +266,88 @@
|
||||
)
|
||||
|
||||
XCTAssertEqual(record, existing)
|
||||
XCTAssertEqual(record.publishedGeneration, 1)
|
||||
XCTAssertEqual(store.saved, [existing])
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
||||
}
|
||||
|
||||
func testExpiredGrantRefreshReusesInstallationIdAndIncrementsGeneration() async throws {
|
||||
let existing = BuzzPushEndpointGrantRecord(
|
||||
relayOrigin: "wss://relay.example",
|
||||
relayPubkey: Self.relayPubkey,
|
||||
installationId: Self.installationId,
|
||||
endpointGrant: "existing-grant",
|
||||
endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))),
|
||||
appProfile: "buzz-ios-sandbox",
|
||||
endpointEpoch: 1,
|
||||
generation: 7,
|
||||
publishedGeneration: 7,
|
||||
expiresAt: Self.now + 300
|
||||
)
|
||||
let store = MemoryGrantStore(records: [existing])
|
||||
let driver = try makeDriver(
|
||||
store: store,
|
||||
appAttest: RecordingAppAttest(),
|
||||
installationIdBytes: {
|
||||
XCTFail("Grant refresh must reuse the persisted installation id")
|
||||
return Data(repeating: 0xFF, count: 16)
|
||||
}
|
||||
)
|
||||
var challengeCount = 0
|
||||
URLProtocolStub.handler = { request in
|
||||
switch (request.httpMethod, request.url?.absoluteString) {
|
||||
case ("GET", "https://relay.example/"):
|
||||
return Self.response(
|
||||
request,
|
||||
status: 200,
|
||||
json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]]
|
||||
)
|
||||
case ("POST", "http://push.example/v1/installations/challenges"):
|
||||
challengeCount += 1
|
||||
return Self.response(
|
||||
request,
|
||||
status: 200,
|
||||
json: [
|
||||
"challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId,
|
||||
"challenge": Self.challenge,
|
||||
"expires_at": Self.now + 300,
|
||||
]
|
||||
)
|
||||
case ("POST", "http://push.example/v1/installations"):
|
||||
return Self.response(
|
||||
request,
|
||||
status: 201,
|
||||
json: [
|
||||
"installation_handle": Self.installationHandle,
|
||||
"endpoint_epoch": 1,
|
||||
"expires_at": Self.expiresAt,
|
||||
]
|
||||
)
|
||||
case ("POST", "http://push.example/v1/delegations"):
|
||||
let body = try Self.body(request)
|
||||
XCTAssertEqual(body["generation"] as? Int, 8)
|
||||
return Self.response(
|
||||
request,
|
||||
status: 201,
|
||||
json: ["endpoint_grant": "refreshed-grant"]
|
||||
)
|
||||
default:
|
||||
XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")")
|
||||
return Self.response(request, status: 500, json: [:])
|
||||
}
|
||||
}
|
||||
|
||||
let record = try await driver.enroll(
|
||||
deviceToken: Data((1...32).map(UInt8.init)),
|
||||
relayURL: Self.relayURL
|
||||
)
|
||||
|
||||
XCTAssertEqual(record.installationId, Self.installationId)
|
||||
XCTAssertEqual(record.generation, 8)
|
||||
XCTAssertNil(record.publishedGeneration)
|
||||
XCTAssertEqual(record.endpointGrant, "refreshed-grant")
|
||||
}
|
||||
|
||||
func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws {
|
||||
let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest())
|
||||
URLProtocolStub.handler = { request in
|
||||
@@ -267,7 +405,10 @@
|
||||
|
||||
private func makeDriver(
|
||||
store: BuzzPushEndpointGrantStore,
|
||||
appAttest: BuzzDevAppAttesting
|
||||
appAttest: BuzzDevAppAttesting,
|
||||
installationIdBytes: @escaping () throws -> Data = {
|
||||
Data(0..<16)
|
||||
}
|
||||
) throws -> BuzzDevPushEnrollmentDriver {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [URLProtocolStub.self]
|
||||
@@ -277,7 +418,8 @@
|
||||
session: URLSession(configuration: configuration),
|
||||
appAttest: appAttest,
|
||||
now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) },
|
||||
lifetimeSeconds: Self.expiresAt - Self.now
|
||||
lifetimeSeconds: Self.expiresAt - Self.now,
|
||||
installationIdBytes: installationIdBytes
|
||||
)
|
||||
}
|
||||
|
||||
@@ -386,6 +528,30 @@
|
||||
init(records: [BuzzPushEndpointGrantRecord] = []) { saved = records }
|
||||
func records() throws -> [BuzzPushEndpointGrantRecord] { saved }
|
||||
func save(_ record: BuzzPushEndpointGrantRecord) throws { saved = [record] }
|
||||
func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws {
|
||||
precondition(generation > 0, "Published push lease generation must be positive")
|
||||
guard
|
||||
let current = saved.first(where: {
|
||||
$0.relayOrigin == relayOrigin && $0.appProfile == appProfile
|
||||
}), current.generation == generation
|
||||
else {
|
||||
throw NSError(domain: "MemoryGrantStore", code: 1)
|
||||
}
|
||||
saved = [
|
||||
BuzzPushEndpointGrantRecord(
|
||||
relayOrigin: current.relayOrigin,
|
||||
relayPubkey: current.relayPubkey,
|
||||
installationId: current.installationId,
|
||||
endpointGrant: current.endpointGrant,
|
||||
endpointHash: current.endpointHash,
|
||||
appProfile: current.appProfile,
|
||||
endpointEpoch: current.endpointEpoch,
|
||||
generation: current.generation,
|
||||
publishedGeneration: generation,
|
||||
expiresAt: current.expiresAt
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private final class RecordingAppAttest: BuzzDevAppAttesting {
|
||||
|
||||
@@ -10,4 +10,5 @@ BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER)
|
||||
BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER)
|
||||
BUZZ_IOS_PUSH_ENVIRONMENT = development
|
||||
BUZZ_APP_ATTEST_ENVIRONMENT = development
|
||||
FLUTTER_TARGET = lib/main_debug.dart
|
||||
#include? "AppOverrides.xcconfig"
|
||||
|
||||
@@ -118,6 +118,8 @@ import UserNotifications
|
||||
#if DEBUG
|
||||
case "devEnrollPush":
|
||||
handleDevPushEnrollment(call, result: result)
|
||||
case "devMarkPushLeasePublished":
|
||||
handleDevPushLeasePublished(call, result: result)
|
||||
#endif
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
@@ -199,6 +201,45 @@ import UserNotifications
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDevPushLeasePublished(
|
||||
_ call: FlutterMethodCall,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
guard let arguments = call.arguments as? [String: Any],
|
||||
let relayOrigin = arguments["relayOrigin"] as? String,
|
||||
let appProfile = arguments["appProfile"] as? String,
|
||||
let generation = (arguments["generation"] as? NSNumber)?.int64Value,
|
||||
!relayOrigin.isEmpty,
|
||||
!appProfile.isEmpty,
|
||||
generation > 0
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments",
|
||||
message: "Published push lease state requires relayOrigin, appProfile, and generation.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try endpointGrantStore.markPublished(
|
||||
relayOrigin: relayOrigin,
|
||||
appProfile: appProfile,
|
||||
generation: generation
|
||||
)
|
||||
result(nil)
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "push_lease_state_write_failed",
|
||||
message: "Unable to persist the published push lease generation.",
|
||||
details: error.localizedDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws {
|
||||
|
||||
@@ -41,7 +41,61 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore {
|
||||
$0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile
|
||||
}
|
||||
all.append(record)
|
||||
let data = try JSONEncoder().encode(all)
|
||||
try replace(all)
|
||||
}
|
||||
|
||||
func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws {
|
||||
precondition(generation > 0, "Published push lease generation must be positive")
|
||||
var all = try records()
|
||||
guard
|
||||
let index = all.firstIndex(where: {
|
||||
$0.relayOrigin == relayOrigin && $0.appProfile == appProfile
|
||||
})
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "BuzzPushEndpointGrantStore",
|
||||
code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cannot mark a missing endpoint grant as published."]
|
||||
)
|
||||
}
|
||||
let current = all[index]
|
||||
guard current.publishedGeneration == nil else {
|
||||
throw NSError(
|
||||
domain: "BuzzPushEndpointGrantStore",
|
||||
code: 3,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Push lease generation \(current.publishedGeneration!) is already marked as published."
|
||||
]
|
||||
)
|
||||
}
|
||||
guard current.generation == generation else {
|
||||
throw NSError(
|
||||
domain: "BuzzPushEndpointGrantStore",
|
||||
code: 4,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey:
|
||||
"Published generation \(generation) does not match stored grant generation \(current.generation)."
|
||||
]
|
||||
)
|
||||
}
|
||||
all[index] = BuzzPushEndpointGrantRecord(
|
||||
relayOrigin: current.relayOrigin,
|
||||
relayPubkey: current.relayPubkey,
|
||||
installationId: current.installationId,
|
||||
endpointGrant: current.endpointGrant,
|
||||
endpointHash: current.endpointHash,
|
||||
appProfile: current.appProfile,
|
||||
endpointEpoch: current.endpointEpoch,
|
||||
generation: current.generation,
|
||||
publishedGeneration: generation,
|
||||
expiresAt: current.expiresAt
|
||||
)
|
||||
try replace(all)
|
||||
}
|
||||
|
||||
private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws {
|
||||
let data = try JSONEncoder().encode(records)
|
||||
let updateStatus = SecItemUpdate(
|
||||
baseQuery() as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary
|
||||
@@ -86,9 +140,10 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore {
|
||||
|
||||
extension BuzzPushEndpointGrantRecord {
|
||||
var flutterArguments: [String: Any] {
|
||||
[
|
||||
var arguments: [String: Any] = [
|
||||
"relayOrigin": relayOrigin,
|
||||
"relayPubkey": relayPubkey,
|
||||
"installationId": installationId,
|
||||
"endpointGrant": endpointGrant,
|
||||
"endpointHash": endpointHash,
|
||||
"appProfile": appProfile,
|
||||
@@ -96,5 +151,9 @@ extension BuzzPushEndpointGrantRecord {
|
||||
"generation": generation,
|
||||
"expiresAt": expiresAt,
|
||||
]
|
||||
if let publishedGeneration {
|
||||
arguments["publishedGeneration"] = publishedGeneration
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import 'app.dart';
|
||||
import 'shared/push/push_bridge.dart';
|
||||
import 'shared/theme/theme_provider.dart';
|
||||
|
||||
void main() async {
|
||||
void main() => runBuzzApp(const App());
|
||||
|
||||
Future<void> runBuzzApp(Widget app) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
installBuzzPushMethodHandler();
|
||||
|
||||
@@ -16,7 +18,7 @@ void main() async {
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
child: const App(),
|
||||
child: app,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
5
mobile/lib/main_debug.dart
Normal file
5
mobile/lib/main_debug.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'app.dart';
|
||||
import 'main.dart' as common;
|
||||
import 'shared/push/debug_push_lease_bootstrap.dart';
|
||||
|
||||
void main() => common.runBuzzApp(const DebugPushLeaseBootstrap(child: App()));
|
||||
82
mobile/lib/shared/push/debug_push_lease_bootstrap.dart
Normal file
82
mobile/lib/shared/push/debug_push_lease_bootstrap.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../relay/relay_provider.dart';
|
||||
import '../relay/relay_session.dart';
|
||||
import '../relay/signed_event_relay.dart';
|
||||
import 'dev_push_lease.dart';
|
||||
import 'push_bridge.dart';
|
||||
|
||||
class DebugPushLeaseBootstrap extends HookConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const DebugPushLeaseBootstrap({required this.child, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
useListenable(apnsDeviceToken);
|
||||
final attempted = useRef<String?>(null);
|
||||
final session = ref.watch(relaySessionProvider);
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final memberPubkey = ref.watch(myPubkeyProvider);
|
||||
final nsec = config.nsec;
|
||||
final deviceToken = apnsDeviceToken.value;
|
||||
useEffect(() {
|
||||
final attemptKey = [
|
||||
session.status.name,
|
||||
config.baseUrl,
|
||||
nsec,
|
||||
memberPubkey,
|
||||
deviceToken,
|
||||
].join('|');
|
||||
if (session.status == SessionStatus.connected &&
|
||||
nsec != null &&
|
||||
memberPubkey != null &&
|
||||
deviceToken != null &&
|
||||
attempted.value != attemptKey) {
|
||||
attempted.value = attemptKey;
|
||||
final relay = SignedEventRelay(
|
||||
session: ref.read(relaySessionProvider.notifier),
|
||||
nsec: nsec,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_publish(config, memberPubkey, relay));
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [session.status, config.baseUrl, nsec, memberPubkey, deviceToken]);
|
||||
return child;
|
||||
}
|
||||
|
||||
Future<void> _publish(
|
||||
RelayConfig config,
|
||||
String memberPubkey,
|
||||
SignedEventRelay relay,
|
||||
) async {
|
||||
final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl);
|
||||
final grant = await enrollBuzzDevPush(config.wsUrl);
|
||||
if (grant.relayOrigin != descriptor.origin) {
|
||||
throw StateError(
|
||||
'Endpoint grant origin ${grant.relayOrigin} does not match descriptor origin ${descriptor.origin}',
|
||||
);
|
||||
}
|
||||
final publishedGeneration = grant.publishedGeneration;
|
||||
if (publishedGeneration == grant.generation) return;
|
||||
|
||||
final nsec = config.nsec;
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
throw StateError('Cannot publish a push lease without a signing key');
|
||||
}
|
||||
await publishBuzzDevPushLeaseThroughRelay(
|
||||
grant: grant,
|
||||
descriptor: descriptor,
|
||||
nsec: nsec,
|
||||
memberPubkey: memberPubkey,
|
||||
relay: relay,
|
||||
);
|
||||
await markBuzzDevPushLeasePublished(grant);
|
||||
}
|
||||
}
|
||||
669
mobile/lib/shared/push/dev_push_lease.dart
Normal file
669
mobile/lib/shared/push/dev_push_lease.dart
Normal file
@@ -0,0 +1,669 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../crypto/nip44.dart';
|
||||
import '../relay/nostr_models.dart';
|
||||
import '../relay/signed_event_relay.dart';
|
||||
import 'push_bridge.dart';
|
||||
|
||||
const buzzPushLeaseKind = 30350;
|
||||
const buzzPushMessageKind = 9;
|
||||
const buzzDevPushAppProfile = 'buzz-ios-sandbox';
|
||||
const buzzPushTransport = 'apns';
|
||||
const _maxSafeJsonInteger = 9007199254740991;
|
||||
const _maxLeaseLifetimeSeconds = 2592000;
|
||||
const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$';
|
||||
const _installationIdPattern = r'^[0-9a-f]{32}$';
|
||||
|
||||
class BuzzPushLeaseDescriptor {
|
||||
final String origin;
|
||||
final String executorKeyId;
|
||||
final String executorPubkey;
|
||||
final String transport;
|
||||
final int maxLeaseTtlSeconds;
|
||||
final int maxContentLength;
|
||||
final int maxPlaintextLength;
|
||||
final int maxEndpointLength;
|
||||
final int maxStringLength;
|
||||
|
||||
const BuzzPushLeaseDescriptor({
|
||||
required this.origin,
|
||||
required this.executorKeyId,
|
||||
required this.executorPubkey,
|
||||
required this.transport,
|
||||
required this.maxLeaseTtlSeconds,
|
||||
required this.maxContentLength,
|
||||
required this.maxPlaintextLength,
|
||||
required this.maxEndpointLength,
|
||||
required this.maxStringLength,
|
||||
});
|
||||
|
||||
factory BuzzPushLeaseDescriptor.fromRelayInformation(
|
||||
Map<String, dynamic> information,
|
||||
) {
|
||||
_requireExactKeys(
|
||||
information,
|
||||
required: const {},
|
||||
allowed: const {
|
||||
'name',
|
||||
'description',
|
||||
'pubkey',
|
||||
'contact',
|
||||
'supported_nips',
|
||||
'supported_extensions',
|
||||
'software',
|
||||
'version',
|
||||
'limitation',
|
||||
'retention',
|
||||
'relay_countries',
|
||||
'language_tags',
|
||||
'tags',
|
||||
'posting_policy',
|
||||
'payments_url',
|
||||
'fees',
|
||||
'icon',
|
||||
'self',
|
||||
'pairing_relay_url',
|
||||
'push',
|
||||
},
|
||||
name: 'NIP-11 document',
|
||||
);
|
||||
final extensions = _stringList(
|
||||
information['supported_extensions'],
|
||||
name: 'supported_extensions',
|
||||
);
|
||||
if (!extensions.contains('nip-pl')) {
|
||||
throw const FormatException('NIP-11 does not advertise nip-pl');
|
||||
}
|
||||
|
||||
final push = _stringMap(information['push'], name: 'push');
|
||||
_requireExactKeys(
|
||||
push,
|
||||
required: const {
|
||||
'origin',
|
||||
'keys',
|
||||
'app_profiles',
|
||||
'push_kinds',
|
||||
'urgent_kinds',
|
||||
'h_grammar',
|
||||
'class_support',
|
||||
'limitation',
|
||||
},
|
||||
allowed: const {
|
||||
'origin',
|
||||
'keys',
|
||||
'app_profiles',
|
||||
'push_kinds',
|
||||
'urgent_kinds',
|
||||
'h_grammar',
|
||||
'class_support',
|
||||
'limitation',
|
||||
},
|
||||
name: 'push descriptor',
|
||||
);
|
||||
|
||||
final origin = _canonicalOrigin(push['origin']);
|
||||
final keys = _mapList(push['keys'], name: 'push.keys');
|
||||
final keyIds = <String>{};
|
||||
final currentKeys = <Map<String, dynamic>>[];
|
||||
for (final key in keys) {
|
||||
_requireExactKeys(
|
||||
key,
|
||||
required: const {'id', 'pubkey'},
|
||||
allowed: const {'id', 'pubkey', 'current', 'retiring'},
|
||||
name: 'push key',
|
||||
);
|
||||
final id = _nonEmptyString(key['id'], name: 'push key id');
|
||||
if (!keyIds.add(id)) {
|
||||
throw FormatException('Duplicate push key id: $id');
|
||||
}
|
||||
_lowercaseHex64(key['pubkey'], name: 'push key pubkey');
|
||||
final current = key['current'];
|
||||
final retiring = key['retiring'];
|
||||
if (current != null && current is! bool) {
|
||||
throw const FormatException('push key current must be a boolean');
|
||||
}
|
||||
if (retiring != null && retiring is! bool) {
|
||||
throw const FormatException('push key retiring must be a boolean');
|
||||
}
|
||||
if (current == true) currentKeys.add(key);
|
||||
}
|
||||
if (currentKeys.length != 1) {
|
||||
throw const FormatException(
|
||||
'push descriptor must contain exactly one current key',
|
||||
);
|
||||
}
|
||||
final currentKey = currentKeys.single;
|
||||
|
||||
final profiles = _mapList(push['app_profiles'], name: 'app_profiles');
|
||||
final profileIds = <String>{};
|
||||
String? transport;
|
||||
for (final profile in profiles) {
|
||||
_requireExactKeys(
|
||||
profile,
|
||||
required: const {'id', 'transport'},
|
||||
allowed: const {'id', 'transport'},
|
||||
name: 'app profile',
|
||||
);
|
||||
final id = _nonEmptyString(profile['id'], name: 'app profile id');
|
||||
if (!profileIds.add(id)) {
|
||||
throw FormatException('Duplicate app profile id: $id');
|
||||
}
|
||||
final candidate = _nonEmptyString(
|
||||
profile['transport'],
|
||||
name: 'app profile transport',
|
||||
);
|
||||
if (id == buzzDevPushAppProfile) transport = candidate;
|
||||
}
|
||||
if (transport != buzzPushTransport) {
|
||||
throw const FormatException(
|
||||
'NIP-11 does not advertise the sandbox APNs profile',
|
||||
);
|
||||
}
|
||||
|
||||
final pushKinds = _intList(push['push_kinds'], name: 'push_kinds');
|
||||
if (!pushKinds.contains(buzzPushMessageKind)) {
|
||||
throw const FormatException('NIP-11 does not advertise kind 9 for push');
|
||||
}
|
||||
final urgentKinds = _intList(
|
||||
push['urgent_kinds'],
|
||||
name: 'urgent_kinds',
|
||||
allowEmpty: true,
|
||||
);
|
||||
if (urgentKinds.any((kind) => !pushKinds.contains(kind))) {
|
||||
throw const FormatException(
|
||||
'urgent_kinds must be a subset of push_kinds',
|
||||
);
|
||||
}
|
||||
final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar');
|
||||
if (hGrammar != 'uuid-v4-lowercase') {
|
||||
throw const FormatException('Unsupported push h_grammar');
|
||||
}
|
||||
|
||||
final classSupport = _stringMap(
|
||||
push['class_support'],
|
||||
name: 'class_support',
|
||||
);
|
||||
final supportedClasses = _stringList(
|
||||
classSupport[buzzPushTransport],
|
||||
name: 'class_support.apns',
|
||||
);
|
||||
const knownClasses = {'silent', 'default', 'time_sensitive', 'urgent'};
|
||||
if (supportedClasses.any((value) => !knownClasses.contains(value))) {
|
||||
throw const FormatException('class_support contains an unknown class');
|
||||
}
|
||||
if (!supportedClasses.contains('default')) {
|
||||
throw const FormatException('APNs does not support the default class');
|
||||
}
|
||||
|
||||
final limitation = _stringMap(push['limitation'], name: 'limitation');
|
||||
_requireExactKeys(
|
||||
limitation,
|
||||
required: const {
|
||||
'max_lease_ttl',
|
||||
'max_leases_per_pubkey',
|
||||
'max_subscriptions_per_lease',
|
||||
'max_kinds',
|
||||
'max_authors',
|
||||
'max_h',
|
||||
'max_tag_values',
|
||||
'max_ignore',
|
||||
'max_content_len',
|
||||
'max_plaintext_len',
|
||||
'max_endpoint_len',
|
||||
'max_string_len',
|
||||
},
|
||||
allowed: const {
|
||||
'max_lease_ttl',
|
||||
'max_leases_per_pubkey',
|
||||
'max_subscriptions_per_lease',
|
||||
'max_kinds',
|
||||
'max_authors',
|
||||
'max_h',
|
||||
'max_tag_values',
|
||||
'max_ignore',
|
||||
'max_content_len',
|
||||
'max_plaintext_len',
|
||||
'max_endpoint_len',
|
||||
'max_string_len',
|
||||
},
|
||||
name: 'push limitation',
|
||||
);
|
||||
for (final entry in limitation.entries) {
|
||||
_positiveInt(entry.value, name: 'limitation.${entry.key}');
|
||||
}
|
||||
final maxStringLength = limitation['max_string_len'] as int;
|
||||
_checkStringLength(origin, maxStringLength, name: 'origin');
|
||||
_checkStringLength(
|
||||
currentKey['id'] as String,
|
||||
maxStringLength,
|
||||
name: 'push key id',
|
||||
);
|
||||
final maxLeaseTtl = limitation['max_lease_ttl'] as int;
|
||||
if (maxLeaseTtl > _maxLeaseLifetimeSeconds) {
|
||||
throw const FormatException('max_lease_ttl exceeds the NIP-PL v1 limit');
|
||||
}
|
||||
|
||||
return BuzzPushLeaseDescriptor(
|
||||
origin: origin,
|
||||
executorKeyId: currentKey['id'] as String,
|
||||
executorPubkey: currentKey['pubkey'] as String,
|
||||
transport: transport!,
|
||||
maxLeaseTtlSeconds: maxLeaseTtl,
|
||||
maxContentLength: limitation['max_content_len'] as int,
|
||||
maxPlaintextLength: limitation['max_plaintext_len'] as int,
|
||||
maxEndpointLength: limitation['max_endpoint_len'] as int,
|
||||
maxStringLength: maxStringLength,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<BuzzPushLeaseDescriptor> fetchBuzzPushLeaseDescriptor(
|
||||
String relayBaseUrl, {
|
||||
http.Client? client,
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final uri = Uri.tryParse(relayBaseUrl);
|
||||
if (uri == null ||
|
||||
!const {'http', 'https'}.contains(uri.scheme) ||
|
||||
uri.host.isEmpty) {
|
||||
throw FormatException('Invalid relay HTTP URL: $relayBaseUrl');
|
||||
}
|
||||
final requestUri = uri.resolve('/');
|
||||
final ownedClient = client ?? http.Client();
|
||||
try {
|
||||
final response = await ownedClient
|
||||
.get(requestUri, headers: const {'Accept': 'application/nostr+json'})
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError(
|
||||
'NIP-11 request failed with HTTP ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('NIP-11 response must be a JSON object');
|
||||
}
|
||||
return BuzzPushLeaseDescriptor.fromRelayInformation(decoded);
|
||||
} finally {
|
||||
if (client == null) ownedClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
class BuzzPushLeasePublication {
|
||||
final String eventId;
|
||||
final int expiration;
|
||||
final String plaintext;
|
||||
|
||||
const BuzzPushLeasePublication({
|
||||
required this.eventId,
|
||||
required this.expiration,
|
||||
required this.plaintext,
|
||||
});
|
||||
}
|
||||
|
||||
typedef BuzzPushLeaseSubmit =
|
||||
Future<NostrEvent> Function({
|
||||
required int kind,
|
||||
required String content,
|
||||
required List<List<String>> tags,
|
||||
int? createdAt,
|
||||
});
|
||||
|
||||
Future<BuzzPushLeasePublication> publishBuzzDevPushLease({
|
||||
required BuzzPushEndpointGrant grant,
|
||||
required BuzzPushLeaseDescriptor descriptor,
|
||||
required String nsec,
|
||||
required String memberPubkey,
|
||||
required BuzzPushLeaseSubmit submit,
|
||||
DateTime Function() now = DateTime.now,
|
||||
}) async {
|
||||
_validateGrant(grant, descriptor);
|
||||
final normalizedMemberPubkey = _lowercaseHex64(
|
||||
memberPubkey,
|
||||
name: 'member pubkey',
|
||||
);
|
||||
final decoded = nostr.Nip19.decode(payload: nsec);
|
||||
if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) {
|
||||
throw const FormatException('Signing key must be a 32-byte nsec');
|
||||
}
|
||||
final signingPubkey = nostr.Keys(decoded.data).public;
|
||||
if (signingPubkey != normalizedMemberPubkey) {
|
||||
throw const FormatException(
|
||||
'Authenticated signing key does not match the lease member pubkey',
|
||||
);
|
||||
}
|
||||
|
||||
final nowSeconds = now().millisecondsSinceEpoch ~/ 1000;
|
||||
final expiration = min(
|
||||
grant.expiresAt,
|
||||
nowSeconds + descriptor.maxLeaseTtlSeconds,
|
||||
);
|
||||
if (expiration <= nowSeconds) {
|
||||
throw const FormatException('Endpoint grant is already expired');
|
||||
}
|
||||
|
||||
final plaintextMap = <String, dynamic>{
|
||||
'v': 1,
|
||||
'origin': descriptor.origin,
|
||||
'app_profile': grant.appProfile,
|
||||
'transport': descriptor.transport,
|
||||
'endpoint': grant.endpointGrant,
|
||||
'generation': grant.generation,
|
||||
'active': true,
|
||||
'subscriptions': [
|
||||
{
|
||||
'filter': {
|
||||
'kinds': [buzzPushMessageKind],
|
||||
'#p': [normalizedMemberPubkey],
|
||||
},
|
||||
'class': 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
validateBuzzPushLeasePlaintext(
|
||||
plaintextMap,
|
||||
maxEndpointLength: descriptor.maxEndpointLength,
|
||||
maxStringLength: descriptor.maxStringLength,
|
||||
);
|
||||
final plaintext = jsonEncode(plaintextMap);
|
||||
if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) {
|
||||
throw const FormatException('lease plaintext exceeds the advertised limit');
|
||||
}
|
||||
final conversationKey = getConversationKey(
|
||||
decoded.data,
|
||||
descriptor.executorPubkey,
|
||||
);
|
||||
final content = nip44Encrypt(conversationKey, plaintext);
|
||||
if (utf8.encode(content).length > descriptor.maxContentLength) {
|
||||
throw const FormatException(
|
||||
'lease ciphertext exceeds the advertised limit',
|
||||
);
|
||||
}
|
||||
final acknowledged = await submit(
|
||||
kind: buzzPushLeaseKind,
|
||||
content: content,
|
||||
tags: [
|
||||
['d', grant.installationId],
|
||||
['expiration', '$expiration'],
|
||||
['exec', descriptor.executorKeyId],
|
||||
],
|
||||
createdAt: nowSeconds,
|
||||
);
|
||||
if (acknowledged.id.isEmpty) {
|
||||
throw StateError('Relay returned an empty event id for the push lease');
|
||||
}
|
||||
return BuzzPushLeasePublication(
|
||||
eventId: acknowledged.id,
|
||||
expiration: expiration,
|
||||
plaintext: plaintext,
|
||||
);
|
||||
}
|
||||
|
||||
Future<BuzzPushLeasePublication> publishBuzzDevPushLeaseThroughRelay({
|
||||
required BuzzPushEndpointGrant grant,
|
||||
required BuzzPushLeaseDescriptor descriptor,
|
||||
required String nsec,
|
||||
required String memberPubkey,
|
||||
required SignedEventRelay relay,
|
||||
DateTime Function() now = DateTime.now,
|
||||
}) => publishBuzzDevPushLease(
|
||||
grant: grant,
|
||||
descriptor: descriptor,
|
||||
nsec: nsec,
|
||||
memberPubkey: memberPubkey,
|
||||
submit: relay.submit,
|
||||
now: now,
|
||||
);
|
||||
|
||||
void validateBuzzPushLeasePlaintext(
|
||||
Map<String, dynamic> plaintext, {
|
||||
int maxEndpointLength = 4096,
|
||||
int maxStringLength = 512,
|
||||
}) {
|
||||
_requireExactKeys(
|
||||
plaintext,
|
||||
required: const {
|
||||
'v',
|
||||
'origin',
|
||||
'app_profile',
|
||||
'transport',
|
||||
'endpoint',
|
||||
'generation',
|
||||
'active',
|
||||
'subscriptions',
|
||||
},
|
||||
allowed: const {
|
||||
'v',
|
||||
'origin',
|
||||
'app_profile',
|
||||
'transport',
|
||||
'endpoint',
|
||||
'generation',
|
||||
'active',
|
||||
'subscriptions',
|
||||
},
|
||||
name: 'lease plaintext',
|
||||
);
|
||||
if (plaintext['v'] != 1) {
|
||||
throw const FormatException('lease version must be 1');
|
||||
}
|
||||
final origin = _canonicalOrigin(plaintext['origin']);
|
||||
_checkStringLength(origin, maxStringLength, name: 'origin');
|
||||
if (plaintext['app_profile'] != buzzDevPushAppProfile) {
|
||||
throw const FormatException('lease app_profile must be buzz-ios-sandbox');
|
||||
}
|
||||
if (plaintext['transport'] != buzzPushTransport) {
|
||||
throw const FormatException('lease transport must be apns');
|
||||
}
|
||||
_checkStringLength(
|
||||
buzzDevPushAppProfile,
|
||||
maxStringLength,
|
||||
name: 'app_profile',
|
||||
);
|
||||
_checkStringLength(buzzPushTransport, maxStringLength, name: 'transport');
|
||||
final endpoint = _nonEmptyString(plaintext['endpoint'], name: 'endpoint');
|
||||
_checkStringLength(endpoint, maxEndpointLength, name: 'endpoint');
|
||||
final generation = _positiveInt(plaintext['generation'], name: 'generation');
|
||||
if (generation > _maxSafeJsonInteger) {
|
||||
throw const FormatException(
|
||||
'generation exceeds the safe JSON integer range',
|
||||
);
|
||||
}
|
||||
if (plaintext['active'] != true) {
|
||||
throw const FormatException('development lease must be active');
|
||||
}
|
||||
|
||||
final subscriptions = _mapList(
|
||||
plaintext['subscriptions'],
|
||||
name: 'subscriptions',
|
||||
);
|
||||
if (subscriptions.length != 1) {
|
||||
throw const FormatException('development lease requires one subscription');
|
||||
}
|
||||
final subscription = subscriptions.single;
|
||||
_requireExactKeys(
|
||||
subscription,
|
||||
required: const {'filter', 'class'},
|
||||
allowed: const {'filter', 'class'},
|
||||
name: 'subscription',
|
||||
);
|
||||
if (subscription['class'] != 'default') {
|
||||
throw const FormatException('development lease class must be default');
|
||||
}
|
||||
final filter = _stringMap(
|
||||
subscription['filter'],
|
||||
name: 'subscription filter',
|
||||
);
|
||||
_requireExactKeys(
|
||||
filter,
|
||||
required: const {'kinds', '#p'},
|
||||
allowed: const {'kinds', '#p'},
|
||||
name: 'subscription filter',
|
||||
);
|
||||
final kinds = _intList(filter['kinds'], name: 'filter kinds');
|
||||
if (kinds.length != 1 || kinds.single != buzzPushMessageKind) {
|
||||
throw const FormatException(
|
||||
'development lease must subscribe to kind 9 only',
|
||||
);
|
||||
}
|
||||
final pubkeys = _stringList(filter['#p'], name: 'filter #p');
|
||||
if (pubkeys.length != 1) {
|
||||
throw const FormatException(
|
||||
'development lease requires one #p member pubkey',
|
||||
);
|
||||
}
|
||||
_lowercaseHex64(pubkeys.single, name: 'filter #p member pubkey');
|
||||
}
|
||||
|
||||
void _validateGrant(
|
||||
BuzzPushEndpointGrant grant,
|
||||
BuzzPushLeaseDescriptor descriptor,
|
||||
) {
|
||||
if (utf8.encode(grant.installationId).length > 64 ||
|
||||
!RegExp(_installationIdPattern).hasMatch(grant.installationId)) {
|
||||
throw const FormatException(
|
||||
'Installation id must be 16 random bytes encoded as lowercase hex',
|
||||
);
|
||||
}
|
||||
if (grant.relayOrigin != descriptor.origin) {
|
||||
throw FormatException(
|
||||
'Stored relay origin ${grant.relayOrigin} does not match descriptor origin ${descriptor.origin}',
|
||||
);
|
||||
}
|
||||
if (grant.relayPubkey != descriptor.executorPubkey) {
|
||||
throw const FormatException(
|
||||
'Stored endpoint grant is delegated to a different relay key',
|
||||
);
|
||||
}
|
||||
if (grant.appProfile != buzzDevPushAppProfile) {
|
||||
throw const FormatException('Endpoint grant is not for buzz-ios-sandbox');
|
||||
}
|
||||
if (grant.endpointGrant.isEmpty ||
|
||||
utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) {
|
||||
throw const FormatException(
|
||||
'Endpoint grant violates the advertised endpoint limit',
|
||||
);
|
||||
}
|
||||
if (grant.endpointEpoch <= 0) {
|
||||
throw const FormatException('Endpoint grant epoch is invalid');
|
||||
}
|
||||
if (grant.generation <= 0 || grant.generation > _maxSafeJsonInteger) {
|
||||
throw const FormatException('Endpoint grant generation is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
String _canonicalOrigin(Object? value) {
|
||||
final origin = _nonEmptyString(value, name: 'origin');
|
||||
final uri = Uri.tryParse(origin);
|
||||
if (uri == null ||
|
||||
!const {'ws', 'wss'}.contains(uri.scheme) ||
|
||||
uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.path.isNotEmpty ||
|
||||
uri.hasQuery ||
|
||||
uri.hasFragment ||
|
||||
'${uri.scheme}://${uri.authority}' != origin) {
|
||||
throw FormatException('Invalid canonical push origin: $origin');
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
void _checkStringLength(String value, int maximum, {required String name}) {
|
||||
if (maximum <= 0 || utf8.encode(value).length > maximum) {
|
||||
throw FormatException('$name exceeds its advertised byte limit');
|
||||
}
|
||||
}
|
||||
|
||||
String _lowercaseHex64(Object? value, {required String name}) {
|
||||
final text = _nonEmptyString(value, name: name);
|
||||
if (!RegExp(_lowercaseHex64Pattern).hasMatch(text)) {
|
||||
throw FormatException('$name must be exactly 64 lowercase hex characters');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
String _nonEmptyString(Object? value, {required String name}) {
|
||||
if (value is! String || value.isEmpty) {
|
||||
throw FormatException('$name must be a non-empty string');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int _positiveInt(Object? value, {required String name}) {
|
||||
if (value is! int || value <= 0) {
|
||||
throw FormatException('$name must be a positive integer');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _stringMap(Object? value, {required String name}) {
|
||||
if (value is! Map<String, dynamic>) {
|
||||
throw FormatException('$name must be an object');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _mapList(Object? value, {required String name}) {
|
||||
if (value is! List || value.isEmpty) {
|
||||
throw FormatException('$name must be a non-empty array');
|
||||
}
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is Map<String, dynamic>)
|
||||
item
|
||||
else
|
||||
throw FormatException('$name entries must be objects'),
|
||||
];
|
||||
}
|
||||
|
||||
List<String> _stringList(Object? value, {required String name}) {
|
||||
if (value is! List || value.isEmpty) {
|
||||
throw FormatException('$name must be a non-empty string array');
|
||||
}
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is String && item.isNotEmpty)
|
||||
item
|
||||
else
|
||||
throw FormatException('$name entries must be non-empty strings'),
|
||||
];
|
||||
}
|
||||
|
||||
List<int> _intList(
|
||||
Object? value, {
|
||||
required String name,
|
||||
bool allowEmpty = false,
|
||||
}) {
|
||||
if (value is! List || (!allowEmpty && value.isEmpty)) {
|
||||
throw FormatException(
|
||||
'$name must be ${allowEmpty ? 'an' : 'a non-empty'} integer array',
|
||||
);
|
||||
}
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is int && item >= 0)
|
||||
item
|
||||
else
|
||||
throw FormatException('$name entries must be non-negative integers'),
|
||||
];
|
||||
}
|
||||
|
||||
void _requireExactKeys(
|
||||
Map<String, dynamic> value, {
|
||||
required Set<String> required,
|
||||
required Set<String> allowed,
|
||||
required String name,
|
||||
}) {
|
||||
final missing = required.difference(value.keys.toSet());
|
||||
if (missing.isNotEmpty) {
|
||||
throw FormatException('$name is missing ${missing.join(', ')}');
|
||||
}
|
||||
final unexpected = value.keys.toSet().difference(allowed);
|
||||
if (unexpected.isNotEmpty) {
|
||||
throw FormatException('$name contains unknown field ${unexpected.first}');
|
||||
}
|
||||
}
|
||||
@@ -20,33 +20,47 @@ final pushEndpointGrantError = ValueNotifier<String?>(null);
|
||||
class BuzzPushEndpointGrant {
|
||||
final String relayOrigin;
|
||||
final String relayPubkey;
|
||||
final String installationId;
|
||||
final String endpointGrant;
|
||||
final String endpointHash;
|
||||
final String appProfile;
|
||||
final int endpointEpoch;
|
||||
final int generation;
|
||||
final int? publishedGeneration;
|
||||
final int expiresAt;
|
||||
|
||||
const BuzzPushEndpointGrant({
|
||||
required this.relayOrigin,
|
||||
required this.relayPubkey,
|
||||
required this.installationId,
|
||||
required this.endpointGrant,
|
||||
required this.endpointHash,
|
||||
required this.appProfile,
|
||||
required this.endpointEpoch,
|
||||
required this.generation,
|
||||
required this.publishedGeneration,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory BuzzPushEndpointGrant.fromMap(Map<dynamic, dynamic> map) {
|
||||
final generation = map['generation'] as int;
|
||||
final publishedGeneration = map['publishedGeneration'] as int?;
|
||||
if (publishedGeneration != null &&
|
||||
(publishedGeneration <= 0 || publishedGeneration > generation)) {
|
||||
throw const FormatException(
|
||||
'Published endpoint grant generation is invalid',
|
||||
);
|
||||
}
|
||||
return BuzzPushEndpointGrant(
|
||||
relayOrigin: map['relayOrigin'] as String,
|
||||
relayPubkey: map['relayPubkey'] as String,
|
||||
installationId: map['installationId'] as String,
|
||||
endpointGrant: map['endpointGrant'] as String,
|
||||
endpointHash: map['endpointHash'] as String,
|
||||
appProfile: map['appProfile'] as String,
|
||||
endpointEpoch: map['endpointEpoch'] as int,
|
||||
generation: map['generation'] as int,
|
||||
generation: generation,
|
||||
publishedGeneration: publishedGeneration,
|
||||
expiresAt: map['expiresAt'] as int,
|
||||
);
|
||||
}
|
||||
@@ -87,6 +101,20 @@ Future<BuzzPushEndpointGrant> enrollBuzzDevPush(String relayUrl) async {
|
||||
return grant;
|
||||
}
|
||||
|
||||
Future<void> markBuzzDevPushLeasePublished(BuzzPushEndpointGrant grant) async {
|
||||
if (!kDebugMode) {
|
||||
throw UnsupportedError(
|
||||
'Development push publication state is unavailable outside debug builds.',
|
||||
);
|
||||
}
|
||||
await _channel.invokeMethod<void>('devMarkPushLeasePublished', {
|
||||
'relayOrigin': grant.relayOrigin,
|
||||
'appProfile': grant.appProfile,
|
||||
'generation': grant.generation,
|
||||
});
|
||||
await readBuzzPushEndpointGrants();
|
||||
}
|
||||
|
||||
/// Latest failure to export the community snapshot used by the iOS
|
||||
/// notification service extension. Snapshot export is push enrichment and must
|
||||
/// never gate authentication or community persistence.
|
||||
|
||||
369
mobile/test/shared/push/dev_push_lease_test.dart
Normal file
369
mobile/test/shared/push/dev_push_lease_test.dart
Normal file
@@ -0,0 +1,369 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/auth/auth_provider.dart';
|
||||
import 'package:buzz/shared/crypto/nip44.dart';
|
||||
import 'package:buzz/shared/push/dev_push_lease.dart';
|
||||
import 'package:buzz/shared/push/push_bridge.dart';
|
||||
import 'package:buzz/shared/relay/nostr_models.dart';
|
||||
import 'package:buzz/shared/relay/relay_session.dart';
|
||||
import 'package:buzz/shared/relay/relay_socket.dart';
|
||||
import 'package:buzz/shared/relay/signed_event_relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
final signer = nostr.Keys.generate();
|
||||
final relay = nostr.Keys.generate();
|
||||
final descriptor = _descriptor(relay.public);
|
||||
final grant = _grant(relay.public);
|
||||
final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000);
|
||||
|
||||
test('publishes strict kind-30350 lease and waits for accepted OK', () async {
|
||||
Map<String, dynamic>? submitted;
|
||||
final publication = await publishBuzzDevPushLease(
|
||||
grant: grant,
|
||||
descriptor: descriptor,
|
||||
nsec: signer.nsec,
|
||||
memberPubkey: signer.public,
|
||||
now: () => now,
|
||||
submit:
|
||||
({required kind, required content, required tags, createdAt}) async {
|
||||
submitted = {
|
||||
'kind': kind,
|
||||
'content': content,
|
||||
'tags': tags,
|
||||
'createdAt': createdAt,
|
||||
};
|
||||
return const NostrEvent(
|
||||
id: 'accepted-id',
|
||||
pubkey: '',
|
||||
createdAt: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: 'saved',
|
||||
sig: '',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(publication.eventId, 'accepted-id');
|
||||
expect(grant.relayOrigin, descriptor.origin);
|
||||
expect(jsonDecode(publication.plaintext)['origin'], descriptor.origin);
|
||||
expect(submitted!['kind'], buzzPushLeaseKind);
|
||||
expect(submitted!['createdAt'], 1752620000);
|
||||
expect(submitted!['tags'], [
|
||||
['d', 'c' * 32],
|
||||
['expiration', '1755212000'],
|
||||
['exec', 'relay-v1'],
|
||||
]);
|
||||
final plaintext = nip44Decrypt(
|
||||
getConversationKey(relay.secret, signer.public),
|
||||
submitted!['content'] as String,
|
||||
);
|
||||
expect(jsonDecode(plaintext), {
|
||||
'v': 1,
|
||||
'origin': 'wss://tenant.example:8443',
|
||||
'app_profile': 'buzz-ios-sandbox',
|
||||
'transport': 'apns',
|
||||
'endpoint': 'opaque-grant',
|
||||
'generation': 1,
|
||||
'active': true,
|
||||
'subscriptions': [
|
||||
{
|
||||
'filter': {
|
||||
'kinds': [9],
|
||||
'#p': [signer.public],
|
||||
},
|
||||
'class': 'default',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'mutation control rejects relay OK false then accepts restored event',
|
||||
() async {
|
||||
final events = <NostrEvent>[];
|
||||
final acknowledgements = <List<dynamic>>[];
|
||||
final session = RelaySessionNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
authProvider.overrideWith(() => _UnauthenticatedAuthNotifier()),
|
||||
],
|
||||
);
|
||||
await container.read(authProvider.future);
|
||||
final subscription = container.listen(relaySessionProvider, (_, _) {});
|
||||
addTearDown(subscription.close);
|
||||
addTearDown(container.dispose);
|
||||
final socket = _MutatingRelaySocket(
|
||||
events,
|
||||
onEvent: (event) => event.kind == 40002
|
||||
? (accepted: false, message: 'invalid: kind not push-eligible')
|
||||
: (accepted: true, message: 'saved'),
|
||||
onAcknowledgement: acknowledgements.add,
|
||||
);
|
||||
session.debugAttachSocketForTest(socket);
|
||||
final relayClient = SignedEventRelay(session: session, nsec: signer.nsec);
|
||||
|
||||
final mutated = relayClient.submit(
|
||||
kind: 40002,
|
||||
content: 'mutated',
|
||||
tags: const [],
|
||||
createdAt: 1752620000,
|
||||
);
|
||||
final rejection = expectLater(
|
||||
mutated,
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('invalid: kind not push-eligible'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await _deliverAcknowledgement(acknowledgements, session);
|
||||
await rejection;
|
||||
final acceptedFuture = relayClient.submit(
|
||||
kind: buzzPushLeaseKind,
|
||||
content: 'restored',
|
||||
tags: [
|
||||
['d', 'c' * 32],
|
||||
['expiration', '1755212000'],
|
||||
['exec', 'relay-v1'],
|
||||
],
|
||||
createdAt: 1752620001,
|
||||
);
|
||||
await _deliverAcknowledgement(acknowledgements, session);
|
||||
final accepted = await acceptedFuture;
|
||||
|
||||
expect(accepted.content, 'saved');
|
||||
expect(events.map((event) => event.kind), [40002, buzzPushLeaseKind]);
|
||||
expect(events.every((event) => event.pubkey == signer.public), isTrue);
|
||||
for (final event in events) {
|
||||
expect(
|
||||
() => nostr.Event(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.createdAt,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
),
|
||||
returnsNormally,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('strict plaintext validator rejects kind 40002 then accepts kind 9', () {
|
||||
final valid = {
|
||||
'v': 1,
|
||||
'origin': 'wss://tenant.example:8443',
|
||||
'app_profile': 'buzz-ios-sandbox',
|
||||
'transport': 'apns',
|
||||
'endpoint': 'opaque-grant',
|
||||
'generation': 1,
|
||||
'active': true,
|
||||
'subscriptions': [
|
||||
{
|
||||
'filter': {
|
||||
'kinds': [40002],
|
||||
'#p': [signer.public],
|
||||
},
|
||||
'class': 'default',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
() => validateBuzzPushLeasePlaintext(valid),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
'development lease must subscribe to kind 9 only',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
((valid['subscriptions'] as List).single['filter'] as Map)['kinds'] = [9];
|
||||
expect(() => validateBuzzPushLeasePlaintext(valid), returnsNormally);
|
||||
});
|
||||
|
||||
test('propagates relay rejection instead of accepting locally', () async {
|
||||
await expectLater(
|
||||
publishBuzzDevPushLease(
|
||||
grant: grant,
|
||||
descriptor: descriptor,
|
||||
nsec: signer.nsec,
|
||||
memberPubkey: signer.public,
|
||||
now: () => now,
|
||||
submit:
|
||||
({
|
||||
required kind,
|
||||
required content,
|
||||
required tags,
|
||||
createdAt,
|
||||
}) async => throw Exception('invalid: origin mismatch'),
|
||||
),
|
||||
throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('invalid: origin mismatch'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('descriptor rejects canonical origin with a trailing slash', () {
|
||||
final information = _descriptorJson(relay.public);
|
||||
(information['push'] as Map<String, dynamic>)['origin'] =
|
||||
'wss://tenant.example:8443/';
|
||||
|
||||
expect(
|
||||
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('descriptor rejects urgent kinds outside push kinds', () {
|
||||
final information = _descriptorJson(relay.public);
|
||||
(information['push'] as Map<String, dynamic>)['urgent_kinds'] = [40002];
|
||||
|
||||
expect(
|
||||
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('descriptor rejects unsupported h grammar', () {
|
||||
final information = _descriptorJson(relay.public);
|
||||
(information['push'] as Map<String, dynamic>)['h_grammar'] = 'opaque';
|
||||
|
||||
expect(
|
||||
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('descriptor rejects unknown push fields', () {
|
||||
final information = _descriptorJson(relay.public);
|
||||
(information['push'] as Map<String, dynamic>)['future'] = true;
|
||||
|
||||
expect(
|
||||
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _UnauthenticatedAuthNotifier extends AuthNotifier {
|
||||
@override
|
||||
Future<AuthState> build() async =>
|
||||
const AuthState(status: AuthStatus.unauthenticated);
|
||||
}
|
||||
|
||||
class _MutatingRelaySocket extends RelaySocket {
|
||||
final List<NostrEvent> events;
|
||||
final ({bool accepted, String message}) Function(NostrEvent event) onEvent;
|
||||
final void Function(List<dynamic> message) _onAcknowledgement;
|
||||
|
||||
_MutatingRelaySocket(
|
||||
this.events, {
|
||||
required this.onEvent,
|
||||
required void Function(List<dynamic> message) onAcknowledgement,
|
||||
}) : _onAcknowledgement = onAcknowledgement,
|
||||
super(
|
||||
wsUrl: 'wss://tenant.example:8443',
|
||||
nsec: null,
|
||||
onMessage: _ignoreMessage,
|
||||
onConnected: _ignoreConnected,
|
||||
onDisconnected: _ignoreDisconnected,
|
||||
);
|
||||
|
||||
@override
|
||||
SocketState get state => SocketState.connected;
|
||||
|
||||
@override
|
||||
void send(List<dynamic> payload) {
|
||||
if (payload case ['EVENT', final Map<String, dynamic> eventJson]) {
|
||||
final event = NostrEvent.fromJson(eventJson);
|
||||
events.add(event);
|
||||
final response = onEvent(event);
|
||||
_onAcknowledgement(['OK', event.id, response.accepted, response.message]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
void _ignoreConnected() {}
|
||||
void _ignoreDisconnected(Object? _) {}
|
||||
void _ignoreMessage(List<dynamic> _) {}
|
||||
|
||||
Future<void> _deliverAcknowledgement(
|
||||
List<List<dynamic>> acknowledgements,
|
||||
RelaySessionNotifier session,
|
||||
) async {
|
||||
while (acknowledgements.isEmpty) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
session.debugHandleMessage(acknowledgements.removeAt(0));
|
||||
}
|
||||
|
||||
BuzzPushLeaseDescriptor _descriptor(String relayPubkey) =>
|
||||
BuzzPushLeaseDescriptor.fromRelayInformation(_descriptorJson(relayPubkey));
|
||||
|
||||
Map<String, dynamic> _descriptorJson(String relayPubkey) => {
|
||||
'supported_extensions': ['nip-er', 'nip-pl'],
|
||||
'push': {
|
||||
'origin': 'wss://tenant.example:8443',
|
||||
'keys': [
|
||||
{'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true},
|
||||
],
|
||||
'app_profiles': [
|
||||
{'id': 'buzz-ios-sandbox', 'transport': 'apns'},
|
||||
],
|
||||
'push_kinds': [7, 9, 1059, 40007, 46010],
|
||||
'urgent_kinds': <int>[],
|
||||
'h_grammar': 'uuid-v4-lowercase',
|
||||
'class_support': {
|
||||
'apns': ['silent', 'default', 'time_sensitive'],
|
||||
},
|
||||
'limitation': {
|
||||
'max_lease_ttl': 2592000,
|
||||
'max_leases_per_pubkey': 16,
|
||||
'max_subscriptions_per_lease': 16,
|
||||
'max_kinds': 16,
|
||||
'max_authors': 20,
|
||||
'max_h': 50,
|
||||
'max_tag_values': 20,
|
||||
'max_ignore': 8,
|
||||
'max_content_len': 65536,
|
||||
'max_plaintext_len': 32768,
|
||||
'max_endpoint_len': 4096,
|
||||
'max_string_len': 512,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
BuzzPushEndpointGrant _grant(String relayPubkey) => BuzzPushEndpointGrant(
|
||||
relayOrigin: 'wss://tenant.example:8443',
|
||||
relayPubkey: relayPubkey,
|
||||
installationId: 'c' * 32,
|
||||
endpointGrant: 'opaque-grant',
|
||||
endpointHash: 'd' * 64,
|
||||
appProfile: 'buzz-ios-sandbox',
|
||||
endpointEpoch: 1,
|
||||
generation: 1,
|
||||
publishedGeneration: null,
|
||||
expiresAt: 1756212000,
|
||||
);
|
||||
@@ -48,13 +48,15 @@ void main() {
|
||||
final grants = await readBuzzPushEndpointGrants();
|
||||
|
||||
expect(grants, hasLength(1));
|
||||
expect(grants.single.relayOrigin, 'wss://relay.example/');
|
||||
expect(grants.single.relayOrigin, 'wss://relay.example');
|
||||
expect(grants.single.relayPubkey, 'a' * 64);
|
||||
expect(grants.single.installationId, 'c' * 32);
|
||||
expect(grants.single.endpointGrant, 'opaque-grant');
|
||||
expect(grants.single.endpointHash, 'b' * 64);
|
||||
expect(grants.single.appProfile, 'buzz-ios-sandbox');
|
||||
expect(grants.single.endpointEpoch, 1);
|
||||
expect(grants.single.generation, 1);
|
||||
expect(grants.single.publishedGeneration, isNull);
|
||||
expect(grants.single.expiresAt, 1752624000);
|
||||
expect(pushEndpointGrants.value.single.endpointGrant, 'opaque-grant');
|
||||
expect(pushEndpointGrantError.value, isNull);
|
||||
@@ -87,6 +89,43 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('persists the acknowledged push lease generation', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final methods = <String>[];
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(_channel, (call) async {
|
||||
methods.add(call.method);
|
||||
if (call.method == 'devMarkPushLeasePublished') {
|
||||
expect(call.arguments, {
|
||||
'relayOrigin': 'wss://relay.example',
|
||||
'appProfile': 'buzz-ios-sandbox',
|
||||
'generation': 1,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (call.method == 'endpointGrants') {
|
||||
return [_grantMap('opaque-grant', publishedGeneration: 1)];
|
||||
}
|
||||
fail('Unexpected method ${call.method}');
|
||||
});
|
||||
|
||||
final grant = BuzzPushEndpointGrant.fromMap(_grantMap('opaque-grant'));
|
||||
await markBuzzDevPushLeasePublished(grant);
|
||||
|
||||
expect(methods, ['devMarkPushLeasePublished', 'endpointGrants']);
|
||||
expect(pushEndpointGrants.value.single.publishedGeneration, 1);
|
||||
});
|
||||
|
||||
test('rejects published generation ahead of the endpoint grant', () {
|
||||
expect(
|
||||
() => BuzzPushEndpointGrant.fromMap(
|
||||
_grantMap('opaque-grant', publishedGeneration: 2),
|
||||
),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('exposes APNs registration failure', () async {
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
@@ -100,13 +139,18 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object> _grantMap(String endpointGrant) => {
|
||||
'relayOrigin': 'wss://relay.example/',
|
||||
Map<String, Object> _grantMap(
|
||||
String endpointGrant, {
|
||||
int? publishedGeneration,
|
||||
}) => {
|
||||
'relayOrigin': 'wss://relay.example',
|
||||
'relayPubkey': 'a' * 64,
|
||||
'installationId': 'c' * 32,
|
||||
'endpointGrant': endpointGrant,
|
||||
'endpointHash': 'b' * 64,
|
||||
'appProfile': 'buzz-ios-sandbox',
|
||||
'endpointEpoch': 1,
|
||||
'generation': 1,
|
||||
'publishedGeneration': ?publishedGeneration,
|
||||
'expiresAt': 1752624000,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user