Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Wevo/UI/Space/ProposeRowViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ final class ProposeRowViewModel {
isCheckingServer = true
serverStatus = .checking

let useCase = CheckProposeServerStatusUseCaseImpl()
let useCase = CheckProposeServerStatusUseCaseImpl(keychainRepository: deps.keychainRepository)
do {
let myPublicKey = defaultIdentity?.publicKey
let result = try await useCase.execute(propose: propose, serverURLs: space.urls, myPublicKey: myPublicKey)
Expand Down Expand Up @@ -302,7 +302,7 @@ final class ProposeRowViewModel {
func acceptServerPropose(_ serverPropose: HashedPropose) async {
isApplyingServerUpdate = true

let useCase = MergeServerSignaturesIntoLocalProposeUseCaseImpl(proposeRepository: deps.proposeRepository)
let useCase = MergeServerSignaturesIntoLocalProposeUseCaseImpl(proposeRepository: deps.proposeRepository, keychainRepository: deps.keychainRepository)
do {
try useCase.execute(proposeID: propose.id, serverPropose: serverPropose)
isApplyingServerUpdate = false
Expand Down
23 changes: 20 additions & 3 deletions Wevo/UseCase/Propose/CheckProposeServerStatusUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ enum CheckProposeServerStatusUseCaseError: Error {
}

struct CheckProposeServerStatusUseCaseImpl {
let keychainRepository: KeychainRepository
let apiClient: ProposeAPIClientProtocol?

init(apiClient: ProposeAPIClientProtocol? = nil) {
init(keychainRepository: KeychainRepository, apiClient: ProposeAPIClientProtocol? = nil) {
self.keychainRepository = keychainRepository
self.apiClient = apiClient
}
}
Expand All @@ -59,16 +61,31 @@ extension CheckProposeServerStatusUseCaseImpl: CheckProposeServerStatusUseCase {

Logger.propose.debug("Server status: \(hashedPropose.status.rawValue, privacy: .public)")

// Verify a server-provided signature against the LOCAL participant key before trusting any
// "server has an update" signal — so a hostile server/MITM cannot drive the UI with forged
// signatures or a fabricated terminal state. (v1: "<verb>." + id + hash + signerKey + ts)
let proposeIDString = propose.id.uuidString
let payloadHash = propose.payloadHash
func verify(_ sig: String?, _ ts: String?, _ signerKey: String, _ verb: String) -> Bool {
guard let sig, let ts else { return false }
let message = verb + "." + proposeIDString + payloadHash + signerKey + ts
return (try? keychainRepository.verifySignature(sig, for: message, withPublicKeyString: signerKey)) == true
}

// Check if the Counterparty has signed on the server but it has not yet been reflected locally (PoC has only 1 counterparty)
var hasPendingCounterpartySignature = false
if let counterparty = hashedPropose.counterparties.first(where: { $0.publicKey == propose.counterpartyPublicKey }),
counterparty.signSignature != nil,
propose.counterpartySignSignature == nil {
propose.counterpartySignSignature == nil,
verify(counterparty.signSignature, counterparty.signTimestamp, propose.counterpartyPublicKey, "signed") {
hasPendingCounterpartySignature = true
Logger.propose.info("Detected Counterparty signature from server: not yet reflected locally")
}

// Check if the server has reached a terminal state (honored/parted/dissolved) not yet reflected locally
// Check if the server has reached a terminal state (honored/parted/dissolved) not yet
// reflected locally. The prompt this drives leads to acceptServerPropose →
// MergeServerSignatures, which verifies every adopted signature, so a forged terminal
// status cannot corrupt local state even though the prompt itself is not gated here.
var hasPendingTerminalStatus = false
let terminalStatuses: Set<ProposeStatus> = [.honored, .parted, .dissolved]
if terminalStatuses.contains(hashedPropose.status),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,85 @@ protocol MergeServerSignaturesIntoLocalProposeUseCase {

struct MergeServerSignaturesIntoLocalProposeUseCaseImpl {
let proposeRepository: ProposeRepository
let keychainRepository: KeychainRepository

init(proposeRepository: ProposeRepository) {
init(proposeRepository: ProposeRepository, keychainRepository: KeychainRepository) {
self.proposeRepository = proposeRepository
self.keychainRepository = keychainRepository
}
}

extension MergeServerSignaturesIntoLocalProposeUseCaseImpl: MergeServerSignaturesIntoLocalProposeUseCase {
func execute(proposeID: UUID, serverPropose: HashedPropose) throws {
let localPropose = try proposeRepository.fetch(by: proposeID)

let counterparty = serverPropose.counterparties.first

// Reflect all server-side signatures and timestamps into the local Propose
// Signature messages are bound to the LOCAL propose's identity (id, content hash) and the
// LOCAL participant keys — never anything the server supplies — so a hostile server/MITM
// cannot substitute keys/content to make forged signatures verify.
let id = localPropose.id.uuidString
let hash = localPropose.payloadHash
let creatorKey = localPropose.creatorPublicKey
let cpKey = localPropose.counterpartyPublicKey

/// Adopts a server-provided signature only when the local slot is empty AND the signature
/// cryptographically verifies (v1: "<verb>." + id + hash + signerKey + timestamp) against
/// the local participant key. Otherwise the local value is kept and the forged/invalid
/// server value is rejected — so unverified server data can never be persisted or trusted.
func adopt(localSig: String?, localTs: String?,
serverSig: String?, serverTs: String?,
verb: String, signerKey: String) -> (String?, String?) {
guard localSig == nil, let serverSig, let serverTs else { return (localSig, localTs) }
let message = verb + "." + id + hash + signerKey + serverTs
if (try? keychainRepository.verifySignature(serverSig, for: message, withPublicKeyString: signerKey)) == true {
return (serverSig, serverTs)
}
Logger.propose.warning("Rejected unverified server '\(verb, privacy: .public)' signature for \(localPropose.id, privacy: .private)")
return (localSig, localTs)
}

let cpSign = adopt(localSig: localPropose.counterpartySignSignature, localTs: localPropose.counterpartySignTimestamp,
serverSig: counterparty?.signSignature, serverTs: counterparty?.signTimestamp, verb: "signed", signerKey: cpKey)
let cpHonor = adopt(localSig: localPropose.counterpartyHonorSignature, localTs: localPropose.counterpartyHonorTimestamp,
serverSig: counterparty?.honorSignature, serverTs: counterparty?.honorTimestamp, verb: "honored", signerKey: cpKey)
let cpPart = adopt(localSig: localPropose.counterpartyPartSignature, localTs: localPropose.counterpartyPartTimestamp,
serverSig: counterparty?.partSignature, serverTs: counterparty?.partTimestamp, verb: "parted", signerKey: cpKey)
let cpDissolve = adopt(localSig: localPropose.counterpartyDissolveSignature, localTs: localPropose.counterpartyDissolveTimestamp,
serverSig: counterparty?.dissolveSignature, serverTs: counterparty?.dissolveTimestamp, verb: "dissolved", signerKey: cpKey)
let crHonor = adopt(localSig: localPropose.creatorHonorSignature, localTs: localPropose.creatorHonorTimestamp,
serverSig: serverPropose.honorCreatorSignature, serverTs: serverPropose.honorCreatorTimestamp, verb: "honored", signerKey: creatorKey)
let crPart = adopt(localSig: localPropose.creatorPartSignature, localTs: localPropose.creatorPartTimestamp,
serverSig: serverPropose.partCreatorSignature, serverTs: serverPropose.partCreatorTimestamp, verb: "parted", signerKey: creatorKey)
let crDissolve = adopt(localSig: localPropose.creatorDissolveSignature, localTs: localPropose.creatorDissolveTimestamp,
serverSig: serverPropose.creatorDissolveSignature, serverTs: serverPropose.creatorDissolveTimestamp, verb: "dissolved", signerKey: creatorKey)

let updatedPropose = Propose(
id: localPropose.id,
spaceID: localPropose.spaceID,
message: localPropose.message,
creatorPublicKey: localPropose.creatorPublicKey,
creatorSignature: localPropose.creatorSignature,
counterpartyPublicKey: localPropose.counterpartyPublicKey,
counterpartySignSignature: counterparty?.signSignature ?? localPropose.counterpartySignSignature,
counterpartySignTimestamp: counterparty?.signTimestamp ?? localPropose.counterpartySignTimestamp,
counterpartyHonorSignature: counterparty?.honorSignature ?? localPropose.counterpartyHonorSignature,
counterpartyHonorTimestamp: counterparty?.honorTimestamp ?? localPropose.counterpartyHonorTimestamp,
counterpartyPartSignature: counterparty?.partSignature ?? localPropose.counterpartyPartSignature,
counterpartyPartTimestamp: counterparty?.partTimestamp ?? localPropose.counterpartyPartTimestamp,
creatorHonorSignature: serverPropose.honorCreatorSignature ?? localPropose.creatorHonorSignature,
creatorHonorTimestamp: serverPropose.honorCreatorTimestamp ?? localPropose.creatorHonorTimestamp,
creatorPartSignature: serverPropose.partCreatorSignature ?? localPropose.creatorPartSignature,
creatorPartTimestamp: serverPropose.partCreatorTimestamp ?? localPropose.creatorPartTimestamp,
creatorDissolveSignature: serverPropose.creatorDissolveSignature ?? localPropose.creatorDissolveSignature,
creatorDissolveTimestamp: serverPropose.creatorDissolveTimestamp ?? localPropose.creatorDissolveTimestamp,
counterpartyDissolveSignature: counterparty?.dissolveSignature ?? localPropose.counterpartyDissolveSignature,
counterpartyDissolveTimestamp: counterparty?.dissolveTimestamp ?? localPropose.counterpartyDissolveTimestamp,
counterpartySignSignature: cpSign.0,
counterpartySignTimestamp: cpSign.1,
counterpartyHonorSignature: cpHonor.0,
counterpartyHonorTimestamp: cpHonor.1,
counterpartyPartSignature: cpPart.0,
counterpartyPartTimestamp: cpPart.1,
creatorHonorSignature: crHonor.0,
creatorHonorTimestamp: crHonor.1,
creatorPartSignature: crPart.0,
creatorPartTimestamp: crPart.1,
creatorDissolveSignature: crDissolve.0,
creatorDissolveTimestamp: crDissolve.1,
counterpartyDissolveSignature: cpDissolve.0,
counterpartyDissolveTimestamp: cpDissolve.1,
signatureVersion: localPropose.signatureVersion,
createdAt: localPropose.createdAt,
updatedAt: Date()
)

// Save locally
try proposeRepository.update(updatedPropose)
Logger.propose.info("Reflected server signatures locally: \(localPropose.id, privacy: .private)")
Logger.propose.info("Reflected verified server signatures locally: \(localPropose.id, privacy: .private)")
}

}
24 changes: 23 additions & 1 deletion Wevo/UseCase/Space/FetchServerInfoUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,32 @@ extension URLSession: HTTPDataFetching {}
struct FetchServerInfoUseCaseImpl: FetchServerInfoUseCase {
private let httpClient: any HTTPDataFetching

/// Upper bound on auto-discovered peers persisted from a single /info response.
static let maxPeers = 16

init(httpClient: any HTTPDataFetching = URLSession.shared) {
self.httpClient = httpClient
}

/// Constrains peer URLs advertised by a server before they are stored and later used for API
/// calls: keep only well-formed absolute http/https URLs with a host, de-duplicate, and cap the
/// count. Prevents a malicious/compromised primary from injecting malformed or odd-scheme
/// endpoints. (http is intentionally still allowed; ATS is disabled by product decision.)
static func sanitizePeers(_ peers: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for raw in peers {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = url.host, !host.isEmpty else { continue }
guard seen.insert(trimmed).inserted else { continue }
result.append(trimmed)
if result.count >= maxPeers { break }
}
return result
}

func execute(urlString: String) async throws -> WevoServerInfo {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard let base = URL(string: trimmed) else {
Expand All @@ -62,7 +84,7 @@ struct FetchServerInfoUseCaseImpl: FetchServerInfoUseCase {

do {
let decoded = try JSONDecoder().decode(InfoResponse.self, from: data)
return WevoServerInfo(peers: decoded.peers)
return WevoServerInfo(peers: Self.sanitizePeers(decoded.peers))
} catch {
throw FetchServerInfoUseCaseError.decodingError(error)
}
Expand Down
Loading