Verify events at RelayConnection

This commit introduces a verification step at the relay connection
level, to help ensure notes get validated at the source and prevent
security issues associated with untrusted relays.

`RelayConnection.swift` — the source that initially handles WebSocket
messages — was analyzed, and measures were put in place to prevent
(or at least minimize) unverified nostr event data being spread
throughout the app.

The following measures were taken:
1. A note verification step was added prior to the `self.handleEvent(.nostr_event(ev))` call (which sends a Nostr response to the rest of the app for logical handling).
    a. From code analysis, there is only one such call in `RelayConnection.swift`.
2. `NostrConnectionEvent`, the object that gets passed to event handlers, had its interface modified to remove the "message" case, since:
    a. that could be a source of unverified nostr events.
    b. it is redundant an unneeded due to the `.nostr_event` case.
    c. there were no usages of it around the codebase
3. The raw websocket event handler had its label renamed to "handleUnverifiedWSEvent", to make it clear to the caller about the verification status of the data.
    a. Usages of this were inspected and no significant risk was detected.
4. A new `verify` method in NdbNote was created to verify Nostr notes, and unit tests were added to confirm tampering detections around all the major fields in a Nostr note.
5. Care was taken to ensure the performance regression is as little as
   possible.

It is worth noting that we will not need this once the local relay model
architecture is introduced, since that architecture ensures note
validation before it reaches the rest of the application and the user.

In other words, this is a temporary fix.

However, since the migration to that new architecture is a major
undertaking that will take some time to be completed, this fix was written
in order to address security concerns while the migration is unfinished.

This fix was written in a way that attempts to be as effective as
possible in reducing security risks without a risky and lenghty
refactor of the code that would delay the fix from being published.

Changelog-Fixed: Improved security around note validation
Closes: https://github.com/damus-io/damus/issues/1341
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-07-30 10:50:10 -07:00
parent d1ea081018
commit 4570ba797c
11 changed files with 131 additions and 15 deletions

View File

@@ -334,6 +334,27 @@ func decode_nostr_event(txt: String) -> NostrResponse? {
return NostrResponse.owned_from_json(json: txt) return NostrResponse.owned_from_json(json: txt)
} }
func decode_and_verify_nostr_response(txt: String) -> NostrResponse? {
guard let response = NostrResponse.owned_from_json(json: txt) else { return nil }
guard verify_nostr_response(response: response) == true else { return nil }
return response
}
func verify_nostr_response(response: borrowing NostrResponse) -> Bool {
switch response {
case .event(_, let event):
return event.verify()
case .notice(_):
return true
case .eose(_):
return true
case .ok(_):
return true
case .auth(_):
return true
}
}
func encode_json<T: Encodable>(_ val: T) -> String? { func encode_json<T: Encodable>(_ val: T) -> String? {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes encoder.outputFormatting = .withoutEscapingSlashes

View File

@@ -9,8 +9,32 @@ import Combine
import Foundation import Foundation
enum NostrConnectionEvent { enum NostrConnectionEvent {
case ws_event(WebSocketEvent) /// Other non-message websocket events
case ws_connection_event(WSConnectionEvent)
/// A nostr response
case nostr_event(NostrResponse) case nostr_event(NostrResponse)
/// Models non-messaging websocket events
///
/// Implementation note: Messaging events should use `.nostr_event` in `NostrConnectionEvent`
enum WSConnectionEvent {
case connected
case disconnected(URLSessionWebSocketTask.CloseCode, String?)
case error(Error)
static func from(full_ws_event: WebSocketEvent) -> Self? {
switch full_ws_event {
case .connected:
return .connected
case .message(_):
return nil
case .disconnected(let closeCode, let string):
return .disconnected(closeCode, string)
case .error(let error):
return .error(error)
}
}
}
} }
final class RelayConnection: ObservableObject { final class RelayConnection: ObservableObject {
@@ -31,11 +55,11 @@ final class RelayConnection: ObservableObject {
init(url: RelayURL, init(url: RelayURL,
handleEvent: @escaping (NostrConnectionEvent) -> (), handleEvent: @escaping (NostrConnectionEvent) -> (),
processEvent: @escaping (WebSocketEvent) -> ()) processUnverifiedWSEvent: @escaping (WebSocketEvent) -> ())
{ {
self.relay_url = url self.relay_url = url
self.handleEvent = handleEvent self.handleEvent = handleEvent
self.processEvent = processEvent self.processEvent = processUnverifiedWSEvent
} }
func ping() { func ping() {
@@ -115,6 +139,7 @@ final class RelayConnection: ObservableObject {
} }
private func receive(event: WebSocketEvent) { private func receive(event: WebSocketEvent) {
assert(!Thread.isMainThread, "This code must not be executed on the main thread")
processEvent(event) processEvent(event)
switch event { switch event {
case .connected: case .connected:
@@ -152,7 +177,8 @@ final class RelayConnection: ObservableObject {
} }
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self.handleEvent(.ws_event(event)) guard let ws_connection_event = NostrConnectionEvent.WSConnectionEvent.from(full_ws_event: event) else { return }
self.handleEvent(.ws_connection_event(ws_connection_event))
} }
if let description = event.description { if let description = event.description {
@@ -190,7 +216,9 @@ final class RelayConnection: ObservableObject {
private func receive(message: URLSessionWebSocketTask.Message) { private func receive(message: URLSessionWebSocketTask.Message) {
switch message { switch message {
case .string(let messageString): case .string(let messageString):
if let ev = decode_nostr_event(txt: messageString) { // NOTE: Once we switch to the local relay model,
// we will not need to verify nostr events at this point.
if let ev = decode_and_verify_nostr_response(txt: messageString) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.handleEvent(.nostr_event(ev)) self.handleEvent(.nostr_event(ev))
} }

View File

@@ -126,7 +126,7 @@ class RelayPool {
} }
let conn = RelayConnection(url: desc.url, handleEvent: { event in let conn = RelayConnection(url: desc.url, handleEvent: { event in
self.handle_event(relay_id: relay_id, event: event) self.handle_event(relay_id: relay_id, event: event)
}, processEvent: { wsev in }, processUnverifiedWSEvent: { wsev in
guard case .message(let msg) = wsev, guard case .message(let msg) = wsev,
case .string(let str) = msg case .string(let str) = msg
else { return } else { return }
@@ -214,9 +214,9 @@ class RelayPool {
var eoseSent = false var eoseSent = false
self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in
switch connectionEvent { switch connectionEvent {
case .ws_event(let ev): case .ws_connection_event(let ev):
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here. // Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
// For the future, perhaps we should abstract away `.ws_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer. // For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
break break
case .nostr_event(let nostrResponse): case .nostr_event(let nostrResponse):
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
@@ -366,7 +366,7 @@ class RelayPool {
record_seen(relay_id: relay_id, event: event) record_seen(relay_id: relay_id, event: event)
// run req queue when we reconnect // run req queue when we reconnect
if case .ws_event(let ws) = event { if case .ws_connection_event(let ws) = event {
if case .connected = ws { if case .connected = ws {
run_queue(relay_id) run_queue(relay_id)
} }

View File

@@ -162,7 +162,7 @@ struct SaveKeysView: View {
func handle_event(relay: RelayURL, ev: NostrConnectionEvent) { func handle_event(relay: RelayURL, ev: NostrConnectionEvent) {
switch ev { switch ev {
case .ws_event(let wsev): case .ws_connection_event(let wsev):
switch wsev { switch wsev {
case .connected: case .connected:
let metadata = create_account_to_metadata(account) let metadata = create_account_to_metadata(account)

View File

@@ -155,7 +155,7 @@ class ProfileModel: ObservableObject, Equatable {
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
switch ev { switch ev {
case .ws_event: case .ws_connection_event:
return return
case .nostr_event(let resp): case .nostr_event(let resp):
guard resp.subid == self.sub_id || resp.subid == self.prof_subid || resp.subid == self.conversations_subid else { guard resp.subid == self.sub_id || resp.subid == self.prof_subid || resp.subid == self.conversations_subid else {

View File

@@ -150,7 +150,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
let now = UInt64(Date.now.timeIntervalSince1970) let now = UInt64(Date.now.timeIntervalSince1970)
switch conn_ev { switch conn_ev {
case .ws_event: case .ws_connection_event:
break break
case .nostr_event(let ev): case .nostr_event(let ev):
guard ev.subid == profiles_subid, rid == relay_id else { return } guard ev.subid == profiles_subid, rid == relay_id else { return }

View File

@@ -109,7 +109,7 @@ func event_matches_filter(_ ev: NostrEvent, filter: NostrFilter) -> Bool {
func handle_subid_event(pool: RelayPool, relay_id: RelayURL, ev: NostrConnectionEvent, handle: (String, NostrEvent) -> ()) -> (String?, Bool) { func handle_subid_event(pool: RelayPool, relay_id: RelayURL, ev: NostrConnectionEvent, handle: (String, NostrEvent) -> ()) -> (String?, Bool) {
switch ev { switch ev {
case .ws_event: case .ws_connection_event:
return (nil, false) return (nil, false)
case .nostr_event(let res): case .nostr_event(let res):

View File

@@ -460,7 +460,7 @@ class HomeModel: ContactsDelegate {
@MainActor @MainActor
func handle_event(relay_id: RelayURL, conn_event: NostrConnectionEvent) { func handle_event(relay_id: RelayURL, conn_event: NostrConnectionEvent) {
switch conn_event { switch conn_event {
case .ws_event(let ev): case .ws_connection_event(let ev):
switch ev { switch ev {
case .connected: case .connected:
if !done_init { if !done_init {

View File

@@ -22,7 +22,7 @@ final class RequestTests: XCTestCase {
let url = RelayURL("wss://example.com")! let url = RelayURL("wss://example.com")!
let relayDescriptor = RelayPool.RelayDescriptor(url: url, info: .readWrite) let relayDescriptor = RelayPool.RelayDescriptor(url: url, info: .readWrite)
let relayConnection = RelayConnection(url: url) { _ in let relayConnection = RelayConnection(url: url) { _ in
} processEvent: { _ in } processUnverifiedWSEvent: { _ in
} }
let relay = RelayPool.Relay(descriptor: relayDescriptor, connection: relayConnection) let relay = RelayPool.Relay(descriptor: relayDescriptor, connection: relayConnection)

View File

@@ -18,6 +18,51 @@ class damusTests: XCTestCase {
override func tearDownWithError() throws { override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class. // Put teardown code here. This method is called after the invocation of each test method in the class.
} }
func testEventVerify() throws {
let test_valid_note_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e6a3","created_at":1753898578,"content":"Test 1102","kind":1,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f152","tags":[]}
"""
let test_invalid_note_tampered_sig_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e6a3","created_at":1753898578,"content":"Test 1102","kind":1,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f153","tags":[]}
"""
let test_invalid_note_tampered_id_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e600","created_at":1753898578,"content":"Test 1102","kind":1,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f152","tags":[]}
"""
let test_invalid_note_tampered_date_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e6a3","created_at":1753898579,"content":"Test 1102","kind":1,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f152","tags":[]}
"""
let test_invalid_note_tampered_pubkey_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e6a3","created_at":1753898578,"content":"Test 1102","kind":1,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084b","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f152","tags":[]}
"""
let test_invalid_note_tampered_content_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e6a3","created_at":1753898578,"content":"Test 1103","kind":1,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f152","tags":[]}
"""
let test_invalid_note_tampered_kind_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e6a3","created_at":1753898578,"content":"Test 1102","kind":2,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f152","tags":[]}
"""
let test_invalid_note_tampered_tags_text = """
{"id":"f4a5635d78d4c1ec2bf7d15d33bd8d5e0afdb8a5a24047f095842281c744e6a3","created_at":1753898578,"content":"Test 1102","kind":1,"pubkey":"056b5b5966f500defb3b790a14633e5ec4a0e8883ca29bc23d0030553edb084a","sig":"d03f0beee7355a8b6ce437b43e01f2d3be8c0f3f17b41a8dec8a9b9804d44ab639b7906c545e4b51820f00b09d00cfa5058916e93126e8a11a65e2623f95f152","tags":[["t", "foo"]]}
"""
let test_valid_note = NdbNote.owned_from_json(json: test_valid_note_text)!
let test_invalid_note_tampered_sig = NdbNote.owned_from_json(json: test_invalid_note_tampered_sig_text)!
var test_invalid_note_tampered_id = NdbNote.owned_from_json(json: test_invalid_note_tampered_id_text)!
let test_invalid_note_tampered_date = NdbNote.owned_from_json(json: test_invalid_note_tampered_date_text)!
let test_invalid_note_tampered_pubkey = NdbNote.owned_from_json(json: test_invalid_note_tampered_pubkey_text)!
let test_invalid_note_tampered_content = NdbNote.owned_from_json(json: test_invalid_note_tampered_content_text)!
let test_invalid_note_tampered_kind = NdbNote.owned_from_json(json: test_invalid_note_tampered_kind_text)!
let test_invalid_note_tampered_tags = NdbNote.owned_from_json(json: test_invalid_note_tampered_tags_text)!
XCTAssertEqual(test_valid_note.verify(), true)
XCTAssertEqual(test_invalid_note_tampered_sig.verify(), false)
XCTAssertEqual(test_invalid_note_tampered_id.verify(), false)
XCTAssertEqual(test_invalid_note_tampered_date.verify(), false)
XCTAssertEqual(test_invalid_note_tampered_pubkey.verify(), false)
XCTAssertEqual(test_invalid_note_tampered_content.verify(), false)
XCTAssertEqual(test_invalid_note_tampered_kind.verify(), false)
XCTAssertEqual(test_invalid_note_tampered_tags.verify(), false)
}
func testIdEquality() throws { func testIdEquality() throws {
let pubkey = test_pubkey let pubkey = test_pubkey

View File

@@ -345,6 +345,28 @@ class NdbNote: Codable, Equatable, Hashable {
json: cstr, json_len: UInt32(json.utf8.count), bufsize: bufsize) json: cstr, json_len: UInt32(json.utf8.count), bufsize: bufsize)
} }
} }
func verify() -> Bool {
let scratch_buf_len = MAX_NOTE_SIZE
let scratch_buf = malloc(scratch_buf_len)
defer { free(scratch_buf) } // Ensure we deallocate as soon as we leave this scope, regardless of the outcome
let current_id = self.id
// Calculate the ID based on the content
guard ndb_calculate_id(self.note.ptr, scratch_buf, Int32(scratch_buf_len)) == 1 else { return false }
let computed_id = self.id
// Ensure computed ID matches given id to prevent ID tampering
guard computed_id == current_id else { return false }
// Verify the signature against the pubkey and the computed ID, to verify the validity of the whole note
var ctx = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_VERIFY))
guard ndb_note_verify(&ctx, ndb_note_pubkey(self.note.ptr), ndb_note_id(self.note.ptr), ndb_note_sig(self.note.ptr)) == 1 else { return false }
return true
}
static func owned_from_json_cstr(json: UnsafePointer<CChar>, json_len: UInt32, bufsize: Int = 2 << 18) -> NdbNote? { static func owned_from_json_cstr(json: UnsafePointer<CChar>, json_len: UInt32, bufsize: Int = 2 << 18) -> NdbNote? {
let data = malloc(bufsize) let data = malloc(bufsize)