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)
}
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? {
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes

View File

@@ -9,8 +9,32 @@ import Combine
import Foundation
enum NostrConnectionEvent {
case ws_event(WebSocketEvent)
/// Other non-message websocket events
case ws_connection_event(WSConnectionEvent)
/// A nostr response
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 {
@@ -31,11 +55,11 @@ final class RelayConnection: ObservableObject {
init(url: RelayURL,
handleEvent: @escaping (NostrConnectionEvent) -> (),
processEvent: @escaping (WebSocketEvent) -> ())
processUnverifiedWSEvent: @escaping (WebSocketEvent) -> ())
{
self.relay_url = url
self.handleEvent = handleEvent
self.processEvent = processEvent
self.processEvent = processUnverifiedWSEvent
}
func ping() {
@@ -115,6 +139,7 @@ final class RelayConnection: ObservableObject {
}
private func receive(event: WebSocketEvent) {
assert(!Thread.isMainThread, "This code must not be executed on the main thread")
processEvent(event)
switch event {
case .connected:
@@ -152,7 +177,8 @@ final class RelayConnection: ObservableObject {
}
}
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 {
@@ -190,7 +216,9 @@ final class RelayConnection: ObservableObject {
private func receive(message: URLSessionWebSocketTask.Message) {
switch message {
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 {
self.handleEvent(.nostr_event(ev))
}

View File

@@ -126,7 +126,7 @@ class RelayPool {
}
let conn = RelayConnection(url: desc.url, handleEvent: { event in
self.handle_event(relay_id: relay_id, event: event)
}, processEvent: { wsev in
}, processUnverifiedWSEvent: { wsev in
guard case .message(let msg) = wsev,
case .string(let str) = msg
else { return }
@@ -214,9 +214,9 @@ class RelayPool {
var eoseSent = false
self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in
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.
// 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
case .nostr_event(let nostrResponse):
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)
// 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 {
run_queue(relay_id)
}

View File

@@ -162,7 +162,7 @@ struct SaveKeysView: View {
func handle_event(relay: RelayURL, ev: NostrConnectionEvent) {
switch ev {
case .ws_event(let wsev):
case .ws_connection_event(let wsev):
switch wsev {
case .connected:
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) {
switch ev {
case .ws_event:
case .ws_connection_event:
return
case .nostr_event(let resp):
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)
switch conn_ev {
case .ws_event:
case .ws_connection_event:
break
case .nostr_event(let ev):
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) {
switch ev {
case .ws_event:
case .ws_connection_event:
return (nil, false)
case .nostr_event(let res):

View File

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

View File

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

View File

@@ -18,6 +18,51 @@ class damusTests: XCTestCase {
override func tearDownWithError() throws {
// 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 {
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)
}
}
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? {
let data = malloc(bufsize)