diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index 09a00d3f2..567884fc2 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -13,7 +13,9 @@ protocol ResultClient: AnyObject { /// the body and `Content-Type: application/json`. Only the JSON goes: the server's solver reads no /// photos and skips the image check for a bare scene.json, so the keyframes stay on the phone /// (and in the scan folder's `scan.zip`, which leaves only if the homeowner shares it). The -/// response body is the result JSON. +/// response body is the result JSON, and it is returned only when it names the scene sent: its +/// `stats.input_sha256` must be present and be the hash of these exact bytes (`ResultBinding`). +/// Any other answer throws, and the upload screen treats it as an answer it couldn't read. @MainActor final class HTTPResultClient: ResultClient { let serverURL: URL @@ -42,6 +44,7 @@ final class HTTPResultClient: ResultClient { status: http.statusCode, body: String(decoding: data.prefix(300), as: UTF8.self), retryAfter: http.value(forHTTPHeaderField: "Retry-After")) } + try ResultBinding.check(answer: data, submittedScene: scene) progress(1) return data } @@ -137,7 +140,9 @@ final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { } /// Answers with the bundled SampleResult.json, for tests and demos without a server. The result -/// is flagged `isSample` so the screen says it is not a real analysis. +/// is flagged `isSample` so the screen says it is not a real analysis. It answers no particular +/// scene (its `input_sha256` is all zeros), so it is deliberately not checked with +/// `ResultBinding`. @MainActor final class SampleResultClient: ResultClient { let isSample = true diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift new file mode 100644 index 000000000..869c59000 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Ties a placement answer to the scene this phone sent. The server writes the SHA-256 of the +/// scene bytes it parsed into `stats.input_sha256`: for a bare scene.json that is the request body +/// itself (server/api.py `_parse` passes the body to `parse_scene`, which hashes it). The result +/// schema requires the field as 64 lowercase hex digits (server/schemas/result.schema.json). +/// +/// An answer is accepted only when it carries that hash and the hash is of the bytes sent. An +/// answer for another scene, or one whose hash is missing or unreadable, is never shown. This is +/// a defence against a mismatched response; nothing shows the current server sending one. +public enum ResultBinding { + public enum Refusal: Error, Equatable, CustomStringConvertible { + /// The answer has no `stats.input_sha256` string, or isn't JSON this check can read. + case noInputHash + /// `stats.input_sha256` isn't 64 lowercase hex digits. + case malformedInputHash(String) + /// The answer names another scene. Both hashes are lowercase hex. + case mismatch(submitted: String, declared: String) + + /// Goes to the device log, so it names what went wrong but never a hash: a scene's hash + /// fingerprints a homeowner's scan. The values stay in the cases for callers and tests. + public var description: String { + switch self { + case .noInputHash: "answer has no readable stats.input_sha256" + case .malformedInputHash(let value): "answer's stats.input_sha256 is malformed (\(value.utf8.count) bytes)" + case .mismatch: "answer's stats.input_sha256 is not the hash of the scene sent" + } + } + } + + /// Throws a `Refusal` unless `answer` declares `stats.input_sha256` as the SHA-256 of `scene`. + /// Anything this can't read refuses the answer: a check that skipped unreadable metadata would + /// let a mismatched answer through whenever the general decoder is more lenient than this one. + public static func check(answer: Data, submittedScene scene: Data) throws { + let declared: String + do { + declared = try JSONDecoder().decode(Answer.self, from: answer).stats.inputSHA256 + } catch { + throw Refusal.noInputHash + } + guard isSHA256Hex(declared) else { throw Refusal.malformedInputHash(declared) } + let submitted = PacketFiles.sha256(scene) + guard declared == submitted else { throw Refusal.mismatch(submitted: submitted, declared: declared) } + } + + /// The schema's form for the field, `^[0-9a-f]{64}$`. + static func isSHA256Hex(_ text: String) -> Bool { + text.utf8.count == 64 && text.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x61...0x66).contains($0) } + } + + private struct Answer: Decodable { + struct Stats: Decodable { + var inputSHA256: String + enum CodingKeys: String, CodingKey { case inputSHA256 = "input_sha256" } + } + var stats: Stats + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift new file mode 100644 index 000000000..93b764da9 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift @@ -0,0 +1,102 @@ +import Foundation +import HouseScanKit +import Testing + +/// A placement answer from the server is shown only for the scene that was sent: its +/// `stats.input_sha256` must be present, in the schema's form, and the SHA-256 of those exact +/// bytes. Built from a real server answer to the synthetic replay. +@Suite struct ResultBindingTests { + static let scene = Data(#"{"note":"the bytes this phone sent"}"#.utf8) + static let fixtureHash = "2cb165cd4d4e9340496d90a589678ccbbe8ae6720d9d73bcae427a6b22075cb7" + static let hashLine = "\"input_sha256\": \"\(fixtureHash)\"," + + static func answer() throws -> String { + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("Schemas/server-answer-synthetic-wall.json") + let text = String(decoding: try Data(contentsOf: url), as: UTF8.self) + #expect(text.contains(hashLine)) + return text + } + + /// The real answer with its `input_sha256` line replaced by `line` (empty removes it). + static func answer(replacingHashLineWith line: String) throws -> Data { + Data(try answer().replacingOccurrences(of: hashLine, with: line).utf8) + } + + static func answer(declaring hash: String) throws -> Data { + try answer(replacingHashLineWith: "\"input_sha256\": \"\(hash)\",") + } + + static func refusal(_ data: Data) throws -> ResultBinding.Refusal { + let refusal = try #require(throws: ResultBinding.Refusal.self) { + try ResultBinding.check(answer: data, submittedScene: Self.scene) + } + // Every refusal is the "couldn't read the answer" failure: back to the review, no retry. + #expect(UploadFailureKind.classify(refusal) == .unreadableAnswer) + #expect(!UploadFailureKind.classify(refusal).retryable) + return refusal + } + + @Test func anAnswerForTheSceneSentPassesAndDecodes() throws { + let data = try Self.answer(declaring: PacketFiles.sha256(Self.scene)) + try ResultBinding.check(answer: data, submittedScene: Self.scene) + #expect(try PlacementResult.decode(data).decision == .manualReview) + } + + @Test func anAnswerForAnotherSceneIsRefused() throws { + let other = PacketFiles.sha256(Data("another scene".utf8)) + #expect(try Self.refusal(Self.answer(declaring: other)) == .mismatch(submitted: PacketFiles.sha256(Self.scene), declared: other)) + } + + /// The upload logs a refusal's description publicly, so it carries no scene hash. + @Test func aRefusalsDescriptionNamesNoHash() throws { + let sent = PacketFiles.sha256(Self.scene) + let other = PacketFiles.sha256(Data("another scene".utf8)) + let refusals: [ResultBinding.Refusal] = [ + try Self.refusal(Self.answer(declaring: other)), + try Self.refusal(Self.answer(declaring: String(other.dropLast()))), + try Self.refusal(Self.answer(replacingHashLineWith: "")), + ] + for refusal in refusals { + let text = String(describing: refusal) + #expect(!text.contains(sent) && !text.contains(other) && !text.contains(String(other.dropLast()))) + } + } + + /// No readable hash refuses the answer, whatever the general decoder would make of it. + @Test(arguments: [ + ("absent", ""), + ("null", "\"input_sha256\": null,"), + ("a number", "\"input_sha256\": 7,"), + ]) + func anAnswerWithoutAHashIsRefused(_ kind: String, line: String) throws { + #expect(try Self.refusal(Self.answer(replacingHashLineWith: line)) == .noInputHash) + } + + /// The schema's form is 64 lowercase hex digits; the right digits in capitals, or too few, + /// are refused rather than compared. + @Test(arguments: ["UPPER", "short", "notHex"]) + func aMalformedHashIsRefused(_ kind: String) throws { + let good = PacketFiles.sha256(Self.scene) + let value = switch kind { + case "UPPER": good.uppercased() + case "short": String(good.dropLast()) + default: String(good.dropLast()) + "z" + } + #expect(try Self.refusal(Self.answer(declaring: value)) == .malformedInputHash(value)) + } + + @Test func anAnswerThatIsNotJSONIsRefused() throws { + #expect(try Self.refusal(Data("not json".utf8)) == .noInputHash) + } + + /// A field this check doesn't read can't make it skip the comparison: an answer for another + /// scene that also carries a number too large for a Double is still refused. + @Test func anUnusualIgnoredFieldDoesNotSkipTheCheck() throws { + let other = PacketFiles.sha256(Data("another scene".utf8)) + let text = try Self.answer().replacingOccurrences(of: Self.hashLine, with: "\"input_sha256\": \"\(other)\", \"extra\": 1e400,") + #expect(throws: ResultBinding.Refusal.self) { + try ResultBinding.check(answer: Data(text.utf8), submittedScene: Self.scene) + } + } +}