From 7c617dc7623674911bf51b17a79a8e1e9c788da0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 13:25:36 -0500 Subject: [PATCH 1/2] ios: a placement answer is shown only when it names the scene that was sent The placement client decoded the answer's stats.input_sha256 but never compared it with what it sent (#144). The server hashes the exact scene bytes it parses: for a bare scene.json that is the request body (server/api.py _parse, server/scene.py). The result schema requires the field as 64 lowercase hex digits. HTTPResultClient now runs ResultBinding.check on every 2xx answer before returning it. The check reads stats.input_sha256 with JSONDecoder, requires the schema's form, and compares it with the SHA-256 of the bytes sent. It fails closed: - an answer it can't read, or one without the field, is refused as noInputHash; - a value that isn't 64 lowercase hex digits is refused as malformedInputHash; - a hash for another scene is refused as mismatch. Each refusal reaches the existing "We couldn't read the server's answer" recovery and isn't retried. SampleResultClient answers no particular scene (its hash is all zeros), so it stays explicitly unchecked. The general PlacementResult decoder and the test fixtures are unchanged. This is defence in depth. Nothing shows the current server sending an answer for another scene. ResultBindingTests is built from a real server answer to the synthetic replay. It covers a matching answer, a mismatch, an absent, null or numeric hash, an uppercase, short or non-hex hash, a body that isn't JSON, and a mismatched answer carrying an unparseable number elsewhere. Co-Authored-By: Claude Code --- ios/HouseScan/Runtime/ResultClient.swift | 9 +- .../HouseScanKit/Scene/ResultBinding.swift | 56 ++++++++++++ .../ResultBindingTests.swift | 87 +++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift 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..a80830ab9 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift @@ -0,0 +1,56 @@ +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) + + 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.prefix(80))" + case .mismatch(let submitted, let declared): "answer is for scene \(declared), not the scene sent (\(submitted))" + } + } + } + + /// 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..d8e6134b2 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift @@ -0,0 +1,87 @@ +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)) + } + + /// 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) + } + } +} From 6ec691228c43b00354088d8fac867f4ac5c4271b Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:21:34 -0500 Subject: [PATCH 2/2] ios: a refused placement answer's log line carries no scene hash ScanEngine logs an upload failure's description with public privacy. ResultBinding.Refusal's description included the full SHA-256 of the scene sent, and for a malformed value the server's declared text (Greptile on #198). A scene's hash fingerprints the homeowner's scan, so it shouldn't be in the device log. The description now says what went wrong and, for a malformed value, its length. The hashes stay in the enum cases for callers and tests. aRefusalsDescriptionNamesNoHash checks all three refusal kinds. Co-Authored-By: Claude Code --- .../HouseScanKit/Scene/ResultBinding.swift | 6 ++++-- .../HouseScanKitTests/ResultBindingTests.swift | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift index a80830ab9..869c59000 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift @@ -17,11 +17,13 @@ public enum ResultBinding { /// 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.prefix(80))" - case .mismatch(let submitted, let declared): "answer is for scene \(declared), not the scene sent (\(submitted))" + 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" } } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift index d8e6134b2..93b764da9 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift @@ -48,6 +48,21 @@ import Testing #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", ""),