diff --git a/PRIVACY.md b/PRIVACY.md index fdf9714..af97685 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -75,10 +75,10 @@ file or vault path, file content, or diagnostic check identifier. ### Optional Helper Diagnostics Runtime -The source tree contains an optional local helper runtime for the authenticated -diagnostics protocol. It is not yet published or deployed, and the released app -does not call it. Installing or upgrading through the ordinary helper installers -does not activate it. The runtime starts only when an operator separately +The published helper 2.0.2 contains an optional local runtime for the +authenticated diagnostics protocol. Publication or installation alone does not +activate it, and the currently released app does not call it. The runtime starts +only when an operator separately supplies both a read-only diagnostics configuration and a writable private state directory; otherwise it creates no listener, credential, mapping, namespace, or artifact. @@ -188,6 +188,47 @@ packages, Docker Desktop, WSL, remote/NAS/FUSE filesystems, Linux binary/systemd installs, macOS packaging, and Windows packaging remain unsupported unless their isolation and rollback are separately proven. +### Optional App Pairing and Namespace Control + +The app source contains a separate Controlled Diagnostics Settings surface for +the authenticated helper control plane. This source is not yet the publicly +released app. Opening the surface performs only a read-only inspection. An app +upgrade, launch, settings visit, Relay wake-up, or ordinary sync creates no +diagnostics key, marker, pairing, network request, namespace, artifact, share, +peer, or trust decision. + +The first mutation requires the user to select one already configured +homeserver and shared folder, accept localized consent, and scan or paste a +five-minute invitation generated by the local helper operator. VaultSync then +stores an installation signing seed and scoped pairing records only in the +dedicated non-synchronizable, device-only diagnostics Keychain service, bound +to a complete-protection app-container marker. The records contain opaque +bindings, public keys and identifiers, epochs, the TLS pin, fixed endpoint, and +exact latest control messages needed for idempotent retry. They contain no QR +secret, note content, user filename, folder path/name, operation payload, or +upload/download history, and they are not sent to Cloud Relay. + +Pairing uses TLS 1.3, an exact QR-pinned leaf SPKI, mutually signed canonical +messages, and an app/operator comparison of a 12-hex transcript fingerprint. +There is no automatic discovery, trust transfer, public default port, Relay +tunnel, redirect, or weaker fallback. Capability negotiation is authenticated +but creates no synchronized artifact and provides no upload, download, or +roundtrip evidence. + +Namespace enablement is a later, separate user action and remains only a signed +request until the helper operator separately confirms the exact folder/path and +retention warning. The app never creates or adopts the visible `VaultSync +Diagnostics` folder. It accepts ownership only after fixed, symlink-resistant +local reads validate the helper-signed root and the exact helper-countersigned +app authorization received through Syncthing. Credential rotation requires a +fresh immutable authorization epoch before operations can resume. + +Revocation, app downgrade, or lost-key recovery stops new app activity but does +not delete the helper authorization, namespace, peer copies, backups, versions, +conflicts, history, or tombstones. Lost-key recovery deliberately requires new +pairing and a separate operator revocation of the surviving old authorization. +See [app capability, pairing, and namespace readiness](docs/app-capability-pairing-namespace-readiness.md). + ### Data Security - APNs device tokens are encrypted at rest (AES-256-GCM) diff --git a/docs/app-capability-pairing-namespace-readiness.md b/docs/app-capability-pairing-namespace-readiness.md new file mode 100644 index 0000000..aa5f129 --- /dev/null +++ b/docs/app-capability-pairing-namespace-readiness.md @@ -0,0 +1,170 @@ +# App capability, pairing, and namespace readiness + +**Status:** App source readiness for the explicit Decision 022/023 control +plane is implemented and locally verified. It is not an App Store release or a +transfer milestone. The published helper baseline is `notify-v2.0.2`; the app +change remains unreleased until its own PR and later release gates complete. +Upload, download, and roundtrip evidence are all unset. + +## User-controlled scope + +Controlled Diagnostics is a separate Settings surface. Opening it performs one +read-only inspection of protected app storage and the dedicated diagnostics +Keychain service. An app upgrade, launch, settings visit, ordinary sync, Relay +wake-up, or background run does not create a key, marker, pairing, endpoint +request, namespace, artifact, Syncthing share, peer, trust decision, or folder +configuration. + +The first mutation requires all of the following explicit actions: + +1. The user selects one already configured homeserver Device ID and one folder + already shared with that device. +2. The user accepts localized pairing consent. +3. The user scans or pastes an operator-generated, five-minute D022 invitation. +4. The app validates the exact target digests, fixed endpoint, helper key, TLS + SPKI pin, canonical CBOR, HMAC, signature suite, nonce, epoch, and clock. +5. The app and local operator compare the same 12-hex transcript fingerprint. +6. Only the user's confirmation advances the persisted types 3, 5, and 7. + +No helper is discovered automatically. The app never uses mDNS, UPnP, public +port defaults, Cloud Relay, APNs, Syncthing discovery, or a Relay tunnel for +this control plane. It never creates or adopts Syncthing trust, a peer, a share, +or a namespace. + +## Credential and transport boundary + +The app stores only its diagnostics installation seed and scoped pairing +records in the dedicated generic-password service +`eu.vaultsync.app.diagnostics.v1`. Items are non-synchronizable and use +`WhenUnlockedThisDeviceOnly`; no shared access group or cloud escrow is used. +A separate complete-protection marker binds the Keychain item to this app +container. A missing or mismatched half is `re-pair required`, never silent key +adoption. + +Each record is scoped to the app installation, homeserver binding, folder +binding, helper, TLS pin, and current app/helper epochs. It retains the exact +latest outgoing/incoming control bytes needed for byte-identical retry, but no +QR secret, folder path, folder/vault name, note content, user filename, +operation payload, upload/download result, or proof history. + +The fixed local/LAN/VPN endpoint uses an ephemeral URL session with TLS 1.3 as +both minimum and maximum, an exact P-256 leaf-SPKI SHA-256 pin, no redirects, +cookies, cache, compression, query, or fragment, fixed CBOR media types and +body limits, and mutually authenticated application signatures. Network +errors become `capability unavailable`; authenticated protocol, tuple, or +mandatory-flag mismatches become `unsupported`. Neither state falls back to a +weaker success. Only the four fixed M3 pairing, capability, namespace- +enablement, and namespace-authorization paths are accepted. A successful +capability response can authorize the next explicit control step only through +its exact signed expiry; it is invalidated on restart, error, or credential +transition. + +Every persisted pending D022/D023 operation also carries an app-local +`mach_continuous_time` deadline bound to its signed wall-clock window. Restart +reconstruction requires both clocks to remain within the original interval; +rolling the wall clock back cannot extend a pairing, namespace, rotation, or +revocation attempt beyond five elapsed minutes. Completed immutable namespace +records remain separately verifiable after that local network-attempt deadline. + +App-key, helper-key, and TLS-pin changes are explicit signed D022 transitions. +The old credential remains authoritative until the terminal acknowledgement +and an exact capability response under the proposed state both validate. A +new app-key generation is selected once in the installation Keychain and reused +for every separately staged folder authorization. Another generation is blocked +until every non-revoked authorization is stable on that selected key; there is +no cross-folder atomicity claim. A +pre-commit transition can be explicitly aborted with signed types 23/24; an +expired pre-commit transition can be discarded only after its signed expiry +and clock-skew window. A type-21 finalization that may have reached the helper +is never silently rolled back. A +completed credential change makes an existing namespace unavailable until the +app and helper append its next immutable D023 authorization epoch. Revocation +is scoped to this app authorization. Lost-key recovery removes only this app's +local diagnostics records and instructs the operator to revoke the surviving +helper authorization separately. + +## Separate namespace enablement + +Pairing and capability checks create no synchronized content. Namespace +enablement is a second explicit app action and remains only a signed request +until the helper operator separately runs the supported installer, confirms +the exact existing folder/path, and accepts visibility and retention. + +The app never creates or adopts `VaultSync Diagnostics`. After the operator +step, it reads only fixed D023 paths beneath the app's existing settled folder +through descriptor-relative, `O_NOFOLLOW` opens. It requires regular, +single-link, size-bounded immutable files, validates the root/helper epoch +chain, and sends the exact app-signed authorization candidate to the pinned +helper. The namespace becomes active only after the helper-countersigned file +arrives through Syncthing and validates against that exact candidate. Rotation +uses append-only authorization epochs 2 through 9. + +The visible namespace and its opaque records can remain on peers, in backups, +Syncthing versions, conflict copies, remote history, and tombstones. Disabling, +revoking, downgrading, or resetting app credentials does not delete those +copies, the namespace root, helper state, a share, or user data. + +## Compatibility matrix + +The diagnostics contract is additive. Trigger v1 and Relay v1 are unchanged. + +| App | Helper | Relay | Honest result | +|---|---|---|---| +| Released old app | Old helper | Existing Relay | Existing behavior only; no diagnostics state. | +| Released old app | Published helper 2.0.2 | Existing or new Relay | Helper remains dormant unless separately configured; the old app makes no diagnostics calls. | +| M3-capable app | Old helper | Any Relay v1 | `Capability unavailable`; no pairing fallback, namespace, or artifact. | +| M3-capable app | Helper 2.0.2, diagnostics unset | Any Relay v1 | `Capability unavailable`; Trigger v1 remains unchanged. | +| M3-capable app | Helper 2.0.2, enabled but unpaired | Any Relay v1 | Explicit QR pairing is offered; no trust or namespace is inherited. | +| M3-capable app | Helper 2.0.2, paired but namespace absent | Any Relay v1 | Authenticated capability can succeed; upload, download, and roundtrip remain unset. | +| M3-capable app | Helper 2.0.2, explicitly namespace-authorized | Existing or new Relay v1 | D022/D023 control plane active; no transfer artifact exists in this milestone. | +| App downgrade | Helper 2.0.2 | Any Relay v1 | Old app ignores the additive records; helper stays dormant for it; credentials and namespace copies are retained. | +| App re-upgrade | Helper 2.0.2 | Any Relay v1 | Read-only reconstruction, fresh capability, and current namespace authorization are required; no operation resumes. | + +## Evidence boundary + +The strongest app-side proof in this milestone is exact production-code +decoding of all D022 types 0–24, byte-exact D024 capability-query generation, +mutually signed capability-response validation, exact D023 golden-chain +validation/generation, device-only credential persistence, and a restart-safe +explicit pairing state machine. This is control-plane evidence only. + +| Claim | State | +|---|---| +| Authenticated capability | Implemented in production app source; cross-language vectors and a deterministic pinned-transport harness pass. Real-device/helper deployment evidence remains unset. | +| Pairing | Explicit, fingerprint-confirmed, scoped, restart-safe D022 state machine. | +| Namespace | Explicit app request plus separate operator creation and helper-countersigned D023 authorization. | +| Upload | Unset; no request artifact is created by this milestone. | +| Download | Unset; no response artifact or fresh `ItemFinished` baseline exists. | +| Roundtrip | Unset; no same-chain directional evidence exists. | +| Cleanup | No app cleanup runtime in this milestone; helper foundation remains evidence-orthogonal. | + +Signatures prove authorship and exact causal bindings. They do not prove a +transport route, direct peer, byte provenance, future delivery, or global sync +health. + +## Local verification + +All Xcode result bundles must be written below `/tmp`. The milestone gate runs: + +```sh +cd ios +xcodegen generate +./scripts/strings-key-parity.sh +xcodebuild -project VaultSync.xcodeproj -scheme VaultSync \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.5' \ + -derivedDataPath /tmp/vaultsync-m3-derived CODE_SIGNING_ALLOWED=NO test \ + -resultBundlePath /tmp/vaultsync-m3-tests.xcresult +``` + +The focused runtime suite covers production D022/D023/D024 golden vectors, +canonical parser rejection, signature/mandatory-flag tampering, exact target +bindings, endpoint literal canonicalization, device-only Keychain query +attributes, existing-user no-mutation, explicit fingerprint gating, persistence, +restart reconstruction, capability expiry, namespace/operator state isolation, +app/helper/TLS rotation, signed pre-commit abort, revocation, persisted monotonic +deadlines with wall-clock rollback, fixed transport paths, arithmetic boundaries, +and honest unavailable/unsupported states. The +pre-PR local run passed all 424 iOS tests; Go bridge and Notify suites, Go Vet, +design-token lint, strings parity, and localized plist lint also passed. These +results are local engineering evidence, not real-device, rollout, or Store +evidence. diff --git a/docs/architecture.md b/docs/architecture.md index 9c0c161..128ccb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,10 +94,12 @@ milestone. #### Opt-in correlated-roundtrip helper runtime — no app evidence yet [Decisions 021–024](decisions/021-capability-negotiated-helper-contract-for-correlated-roundtrip-proof.md) -define the proof and rollout boundaries. The source tree now contains the first -helper runtime carrier for those foundations, but it is not yet published or -deployed and no app runtime calls it. VaultSync 2.0 remains NO-GO; product -upload, controlled download, and causal roundtrip evidence are all unset. +define the proof and rollout boundaries. Helper 2.0.2 is published and its +immutable digest plus upgrade, downgrade, and forward-recovery path are +verified. The source tree now also contains the unreleased app-side explicit +capability, pairing, credential-lifecycle, and namespace-authorization control +plane. VaultSync 2.0 remains NO-GO; product upload, controlled download, and +causal roundtrip evidence are all unset. The runtime is gated by an operator-authored read-only configuration plus a separate writable state directory. If either is absent, existing helpers retain @@ -197,6 +199,9 @@ fresh post-authorization iPhone cursor/nanosecond/generation/`ItemFinished` baseline. Cleanup remains evidence-orthogonal. Helper-first publication, production rollout, rollback, and then separate app milestones remain mandatory. See [helper runtime and packaging readiness](helper-runtime-packaging-readiness.md). +The app-side scope, compatibility, persistence, consent, and rollback boundaries +are documented in +[app capability, pairing, and namespace readiness](app-capability-pairing-namespace-readiness.md). ### Connection paths & iOS network privacy diff --git a/docs/helper-runtime-packaging-readiness.md b/docs/helper-runtime-packaging-readiness.md index 18f5a71..ad0268a 100644 --- a/docs/helper-runtime-packaging-readiness.md +++ b/docs/helper-runtime-packaging-readiness.md @@ -197,9 +197,10 @@ their existing wire formats. | Future capable | New, enabled and exactly authorized | Existing v1 | Helper-side D022–D024 contract is available; Relay v1 is unchanged and is not evidence for upload, download, or roundtrip. | | Any | New → old rollback → same new image | Existing v1 | Diagnostics becomes unavailable on rollback; preserved state is revalidated on forward recovery and no operation resumes automatically. | -This milestone does not claim compatibility with an unreleased app -implementation. App-side old/new matrices and real-device evidence remain -gates for the later app milestones. +This helper milestone does not claim compatibility with a released capable app. +The now-implemented, still-unreleased app control-plane matrix is tracked in +[app capability, pairing, and namespace readiness](app-capability-pairing-namespace-readiness.md); +transfer and real-device evidence remain later gates. ## Evidence boundary diff --git a/ios/VaultSync/Services/DiagnosticsCBOR.swift b/ios/VaultSync/Services/DiagnosticsCBOR.swift new file mode 100644 index 0000000..b06c655 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsCBOR.swift @@ -0,0 +1,415 @@ +import CryptoKit +import Foundation +import Security + +enum DiagnosticsProtocolError: Error, Equatable, Sendable { + case invalidMessage + case expired + case unavailable + case unsupported + case conflict + case rateLimited + case protectedDataUnavailable + case recoveryRequired +} + +indirect enum DiagnosticsCBORValue: Equatable, Sendable { + case unsigned(UInt64) + case bytes(Data) + case text(String) + case array([DiagnosticsCBORValue]) + case map([DiagnosticsCBORField]) +} + +struct DiagnosticsCBORField: Equatable, Sendable { + let label: UInt64 + var value: DiagnosticsCBORValue +} + +enum DiagnosticsDeterministicCBOR { + static let maximumMessageBytes = 16 * 1024 + static let maximumMapEntries = 32 + static let maximumArrayEntries = 8 + static let maximumNestingDepth = 4 + + static func encode(_ value: DiagnosticsCBORValue) throws -> Data { + var data = Data() + try append(value, to: &data, depth: 0) + guard data.count <= maximumMessageBytes else { + throw DiagnosticsProtocolError.invalidMessage + } + return data + } + + static func decode(_ data: Data) throws -> DiagnosticsCBORValue { + guard !data.isEmpty, data.count <= maximumMessageBytes else { + throw DiagnosticsProtocolError.invalidMessage + } + var decoder = Decoder(data: data) + let value = try decoder.decode(depth: 0) + guard decoder.index == data.count, try encode(value) == data else { + throw DiagnosticsProtocolError.invalidMessage + } + return value + } + + private static func append(_ value: DiagnosticsCBORValue, to data: inout Data, depth: Int) throws { + guard depth <= maximumNestingDepth else { + throw DiagnosticsProtocolError.invalidMessage + } + switch value { + case .unsigned(let number): + appendHead(major: 0, value: number, to: &data) + case .bytes(let bytes): + appendHead(major: 2, value: UInt64(bytes.count), to: &data) + data.append(bytes) + case .text(let text): + guard text.unicodeScalars.allSatisfy({ $0.value <= 0x7f }) else { + throw DiagnosticsProtocolError.invalidMessage + } + let bytes = Data(text.utf8) + appendHead(major: 3, value: UInt64(bytes.count), to: &data) + data.append(bytes) + case .array(let values): + guard values.count <= maximumArrayEntries else { + throw DiagnosticsProtocolError.invalidMessage + } + appendHead(major: 4, value: UInt64(values.count), to: &data) + for child in values { + try append(child, to: &data, depth: depth + 1) + } + case .map(let inputFields): + guard inputFields.count <= maximumMapEntries else { + throw DiagnosticsProtocolError.invalidMessage + } + let fields = inputFields.sorted { $0.label < $1.label } + for pair in zip(fields, fields.dropFirst()) where pair.0.label == pair.1.label { + throw DiagnosticsProtocolError.invalidMessage + } + appendHead(major: 5, value: UInt64(fields.count), to: &data) + for field in fields { + appendHead(major: 0, value: field.label, to: &data) + try append(field.value, to: &data, depth: depth + 1) + } + } + } + + private static func appendHead(major: UInt8, value: UInt64, to data: inout Data) { + switch value { + case 0..<24: + data.append(major << 5 | UInt8(value)) + case 24...UInt64(UInt8.max): + data.append(major << 5 | 24) + data.append(UInt8(value)) + case 0x100...UInt64(UInt16.max): + data.append(major << 5 | 25) + appendBigEndian(value, bytes: 2, to: &data) + case 0x1_0000...UInt64(UInt32.max): + data.append(major << 5 | 26) + appendBigEndian(value, bytes: 4, to: &data) + default: + data.append(major << 5 | 27) + appendBigEndian(value, bytes: 8, to: &data) + } + } + + private static func appendBigEndian(_ value: UInt64, bytes: Int, to data: inout Data) { + for shift in stride(from: (bytes - 1) * 8, through: 0, by: -8) { + data.append(UInt8(truncatingIfNeeded: value >> UInt64(shift))) + } + } + + private struct Decoder { + let data: Data + var index = 0 + + mutating func decode(depth: Int) throws -> DiagnosticsCBORValue { + guard depth <= DiagnosticsDeterministicCBOR.maximumNestingDepth else { + throw DiagnosticsProtocolError.invalidMessage + } + let initial = try readByte() + let major = initial >> 5 + let argument = try readArgument(additional: initial & 0x1f) + switch major { + case 0: + return .unsigned(argument) + case 2: + return .bytes(try readBody(length: argument)) + case 3: + let body = try readBody(length: argument) + guard let text = String(data: body, encoding: .utf8), + text.unicodeScalars.allSatisfy({ $0.value <= 0x7f }) else { + throw DiagnosticsProtocolError.invalidMessage + } + return .text(text) + case 4: + guard argument <= DiagnosticsDeterministicCBOR.maximumArrayEntries else { + throw DiagnosticsProtocolError.invalidMessage + } + var values: [DiagnosticsCBORValue] = [] + values.reserveCapacity(Int(argument)) + for _ in 0.. previous! else { + throw DiagnosticsProtocolError.invalidMessage + } + previous = label + fields.append(DiagnosticsCBORField(label: label, value: try decode(depth: depth + 1))) + } + return .map(fields) + default: + throw DiagnosticsProtocolError.invalidMessage + } + } + + private mutating func readArgument(additional: UInt8) throws -> UInt64 { + switch additional { + case 0..<24: + return UInt64(additional) + case 24: + let value = UInt64(try readByte()) + guard value >= 24 else { throw DiagnosticsProtocolError.invalidMessage } + return value + case 25: + let value = try readBigEndian(bytes: 2) + guard value > UInt8.max else { throw DiagnosticsProtocolError.invalidMessage } + return value + case 26: + let value = try readBigEndian(bytes: 4) + guard value > UInt16.max else { throw DiagnosticsProtocolError.invalidMessage } + return value + case 27: + let value = try readBigEndian(bytes: 8) + guard value > UInt32.max else { throw DiagnosticsProtocolError.invalidMessage } + return value + default: + throw DiagnosticsProtocolError.invalidMessage + } + } + + private mutating func readBigEndian(bytes: Int) throws -> UInt64 { + var value: UInt64 = 0 + for _ in 0.. Data { + guard length <= DiagnosticsDeterministicCBOR.maximumMessageBytes, + length <= UInt64(data.count - index) else { + throw DiagnosticsProtocolError.invalidMessage + } + let start = index + index += Int(length) + return data.subdata(in: start.. UInt8 { + guard index < data.count else { throw DiagnosticsProtocolError.invalidMessage } + defer { index += 1 } + return data[index] + } + } +} + +extension DiagnosticsCBORValue { + var fields: [DiagnosticsCBORField]? { + guard case .map(let fields) = self else { return nil } + return fields + } + + func value(for label: UInt64) -> DiagnosticsCBORValue? { + fields?.first(where: { $0.label == label })?.value + } + + func unsigned(for label: UInt64) -> UInt64? { + guard case .unsigned(let value) = value(for: label) else { return nil } + return value + } + + func bytes(for label: UInt64, count: Int? = nil) -> Data? { + guard case .bytes(let value) = value(for: label), count == nil || value.count == count else { return nil } + return value + } + + func text(for label: UInt64) -> String? { + guard case .text(let value) = value(for: label) else { return nil } + return value + } + + func removing(labels: Set) -> DiagnosticsCBORValue { + guard case .map(let fields) = self else { return self } + return .map(fields.filter { !labels.contains($0.label) }) + } +} + +enum DiagnosticsCrypto { + static func randomBytes(count: Int) throws -> Data { + guard count > 0 else { throw DiagnosticsProtocolError.invalidMessage } + var data = Data(repeating: 0, count: count) + let status = data.withUnsafeMutableBytes { bytes in + SecRandomCopyBytes(kSecRandomDefault, count, bytes.baseAddress!) + } + guard status == errSecSuccess, data.contains(where: { $0 != 0 }) else { + throw DiagnosticsProtocolError.unavailable + } + return data + } + + static func sha256(_ data: Data) -> Data { + Data(SHA256.hash(data: data)) + } + + static func sha256(domain: String, body: Data) -> Data { + var input = Data(domain.utf8) + input.append(body) + return sha256(input) + } + + static func hmacSHA256(key: Data, domain: String, body: Data) -> Data { + var input = Data(domain.utf8) + input.append(body) + return Data(HMAC.authenticationCode(for: input, using: SymmetricKey(data: key))) + } + + static func keyID(publicKey: Data) -> Data { + sha256(domain: "eu.vaultsync.key-id/ed25519/v1\0", body: publicKey) + } + + static func signedMessageDigest(domain: String, value: DiagnosticsCBORValue) throws -> Data { + sha256(domain: domain, body: try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255]))) + } + + static func base64URLDecode(_ encoded: String) throws -> Data { + let maximumEncodedBytes = (DiagnosticsDeterministicCBOR.maximumMessageBytes * 4 + 2) / 3 + guard !encoded.isEmpty, + encoded.utf8.count <= maximumEncodedBytes, + !encoded.contains("="), + encoded.utf8.allSatisfy({ + ($0 >= 0x41 && $0 <= 0x5a) || ($0 >= 0x61 && $0 <= 0x7a) || + ($0 >= 0x30 && $0 <= 0x39) || $0 == 0x2d || $0 == 0x5f + }) else { + throw DiagnosticsProtocolError.invalidMessage + } + var standard = encoded.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + standard.append(String(repeating: "=", count: (4 - standard.count % 4) % 4)) + guard let data = Data(base64Encoded: standard), base64URLEncode(data) == encoded else { + throw DiagnosticsProtocolError.invalidMessage + } + return data + } + + static func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func fingerprint(appRequestDigest: Data, helperAcceptDigest: Data) throws -> String { + guard appRequestDigest.count == 32, helperAcceptDigest.count == 32 else { + throw DiagnosticsProtocolError.invalidMessage + } + var body = appRequestDigest + body.append(helperAcceptDigest) + return sha256(domain: "eu.vaultsync.helper-pairing/v1/transcript-fingerprint\0", body: body) + .prefix(6) + .map { String(format: "%02X", $0) } + .joined() + } +} + +enum DiagnosticsSyncthingBinding { + private static let base32Alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".utf8) + + static func rawDeviceID(_ display: String) throws -> Data { + let compact = display.replacingOccurrences(of: "-", with: "") + guard compact.count == 56, + compact.utf8.allSatisfy({ base32Alphabet.contains($0) }) else { + throw DiagnosticsProtocolError.unsupported + } + let bytes = Array(compact.utf8) + let canonical = stride(from: 0, to: bytes.count, by: 7) + .map { String(decoding: bytes[$0..<($0 + 7)], as: UTF8.self) } + .joined(separator: "-") + guard display == canonical else { throw DiagnosticsProtocolError.unsupported } + var encoded: [UInt8] = [] + encoded.reserveCapacity(52) + for block in 0..<4 { + let start = block * 14 + let body = Array(bytes[start..<(start + 13)]) + guard bytes[start + 13] == luhn32(body) else { + throw DiagnosticsProtocolError.unsupported + } + encoded.append(contentsOf: body) + } + let decoded = try decodeBase32(encoded) + guard decoded.count == 32 else { throw DiagnosticsProtocolError.unsupported } + return decoded + } + + static func deviceDigest(_ display: String) throws -> Data { + DiagnosticsCrypto.sha256( + domain: "eu.vaultsync.binding/syncthing-device/v1\0", + body: try rawDeviceID(display) + ) + } + + static func folderDigest(_ folderID: String) throws -> Data { + let identifier = Data(folderID.utf8) + guard !identifier.isEmpty, identifier.count <= 255, + String(data: identifier, encoding: .utf8) == folderID else { + throw DiagnosticsProtocolError.unsupported + } + var body = Data() + let length = UInt32(identifier.count).bigEndian + withUnsafeBytes(of: length) { body.append(contentsOf: $0) } + body.append(identifier) + return DiagnosticsCrypto.sha256(domain: "eu.vaultsync.binding/syncthing-folder/v1\0", body: body) + } + + private static func luhn32(_ input: [UInt8]) -> UInt8 { + var factor = 1 + var sum = 0 + for character in input { + let codepoint = base32Alphabet.firstIndex(of: character)! + let multiplied = factor * codepoint + factor = factor == 2 ? 1 : 2 + sum += multiplied / 32 + multiplied % 32 + } + return base32Alphabet[(32 - sum % 32) % 32] + } + + private static func decodeBase32(_ input: [UInt8]) throws -> Data { + var output = Data() + var buffer: UInt64 = 0 + var bits = 0 + for character in input { + guard let index = base32Alphabet.firstIndex(of: character) else { + throw DiagnosticsProtocolError.unsupported + } + buffer = (buffer << 5) | UInt64(index) + bits += 5 + while bits >= 8 { + bits -= 8 + output.append(UInt8((buffer >> UInt64(bits)) & 0xff)) + buffer &= bits == 0 ? 0 : (1 << UInt64(bits)) - 1 + } + } + guard bits == 0 || buffer == 0 else { throw DiagnosticsProtocolError.unsupported } + return output + } +} diff --git a/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift b/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift new file mode 100644 index 0000000..87136f0 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift @@ -0,0 +1,664 @@ +import CryptoKit +import Foundation + +enum DiagnosticsCapabilityProtocol { + static let capability = "eu.vaultsync.diagnostics.correlated-roundtrip/1" + static let path = "/api/v1/diagnostics/capability" + static let requiredFlags: UInt64 = 0x0f + private static let queryDomain = "eu.vaultsync.roundtrip/v1/capability-query\0" + private static let responseDomain = "eu.vaultsync.roundtrip/v1/capability-response\0" + + struct Query: Sendable { + let message: Data + let digest: Data + let nonce: Data + } + + static func makeQuery( + record: DiagnosticsPairingRecord, + appKey: Curve25519.Signing.PrivateKey, + nonce: Data, + now: Date + ) throws -> Query { + guard nonce.count == 32, + appKey.publicKey.rawRepresentation == record.appPublicKey else { + throw DiagnosticsProtocolError.invalidMessage + } + let issued = try unixSeconds(now) + let body = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(1)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 8, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 9, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 10, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 12, value: .unsigned(issued)), + DiagnosticsCBORField( + label: 13, + value: .unsigned(try DiagnosticsPairingProtocol.checkedAdding(issued, 120)) + ), + DiagnosticsCBORField(label: 30, value: .bytes(nonce)), + ]) + try validate(body, type: 1, record: record, omittedSignature: true) + let bodyBytes = try DiagnosticsDeterministicCBOR.encode(body) + var signedInput = Data(queryDomain.utf8) + signedInput.append(bodyBytes) + let signature = try appKey.signature(for: signedInput) + guard case .map(var fields) = body else { throw DiagnosticsProtocolError.invalidMessage } + fields.append(DiagnosticsCBORField(label: 255, value: .bytes(signature))) + let message = try DiagnosticsDeterministicCBOR.encode(.map(fields)) + return Query( + message: message, + digest: DiagnosticsCrypto.sha256(domain: queryDomain, body: bodyBytes), + nonce: nonce + ) + } + + @discardableResult + static func validateResponse( + _ data: Data, + query: Query, + record: DiagnosticsPairingRecord, + now: Date + ) throws -> UInt64 { + let value = try DiagnosticsDeterministicCBOR.decode(data) + try validate(value, type: 2, record: record, omittedSignature: false) + guard value.bytes(for: 30, count: 32) == query.nonce, + value.bytes(for: 31, count: 32) == query.digest, + value.unsigned(for: 27) == requiredFlags, + let signature = value.bytes(for: 255, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + var signedInput = Data(responseDomain.utf8) + signedInput.append(body) + let helperKey = try Curve25519.Signing.PublicKey(rawRepresentation: record.helperPublicKey) + guard helperKey.isValidSignature(signature, for: signedInput) else { + throw DiagnosticsProtocolError.invalidMessage + } + let current = try unixSeconds(now) + guard let issued = value.unsigned(for: 12), let expires = value.unsigned(for: 13), + issued <= (try DiagnosticsPairingProtocol.checkedAdding(current, 120)), + current <= (try DiagnosticsPairingProtocol.checkedAdding(expires, 120)) else { + throw DiagnosticsProtocolError.expired + } + return expires + } + + private static func validate( + _ value: DiagnosticsCBORValue, + type: UInt64, + record: DiagnosticsPairingRecord, + omittedSignature: Bool + ) throws { + let labels: [UInt64] = type == 1 + ? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 30] + (omittedSignature ? [] : [255]) + : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 27, 30, 31, 255] + guard value.fields?.map(\.label) == labels.sorted(), + value.text(for: 1) == capability, + value.unsigned(for: 2) == 1, + value.unsigned(for: 3) == 1, + value.unsigned(for: 4) == type, + value.bytes(for: 5, count: 32) == record.homeserverBinding, + value.bytes(for: 6, count: 32) == record.folderBinding, + value.bytes(for: 7, count: 32) == record.appKeyID, + value.bytes(for: 8, count: 32) == record.helperKeyID, + value.unsigned(for: 9) == record.appEpoch, + value.unsigned(for: 10) == record.helperEpoch, + value.bytes(for: 30, count: 32)?.contains(where: { $0 != 0 }) == true, + let issued = value.unsigned(for: 12), issued > 0, + let expires = value.unsigned(for: 13), expires > issued, expires - issued <= 120 else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + private static func unixSeconds(_ date: Date) throws -> UInt64 { + let value = date.timeIntervalSince1970.rounded(.down) + guard value >= 0, value < Double(UInt64.max) else { + throw DiagnosticsProtocolError.invalidMessage + } + return UInt64(value) + } +} + +enum DiagnosticsNamespaceProtocol { + static let capability = "eu.vaultsync.diagnostics.namespace/1" + static let enablementPath = "/api/v1/diagnostics/namespace/enablement" + static let authorizationPath = "/api/v1/diagnostics/namespace/authorization" + static let rootName = "VaultSync Diagnostics" + static let rootManifestName = "root-manifest.cbor" + private static let recordDigestDomain = "eu.vaultsync.namespace/v1/record-digest\0" + private static let installationBindingDomain = "eu.vaultsync.namespace/installation/v1\0" + private static let enablementDomain = "eu.vaultsync.namespace/v1/enablement-request\0" + private static let rootDomain = "eu.vaultsync.namespace/v1/root-manifest\0" + private static let authorizationAppDomain = "eu.vaultsync.namespace/v1/authorization-initial-app\0" + private static let authorizationHelperDomain = "eu.vaultsync.namespace/v1/authorization-initial-helper\0" + private static let authorizationEpochAppDomain = "eu.vaultsync.namespace/v1/authorization-epoch-app\0" + private static let authorizationEpochHelperDomain = "eu.vaultsync.namespace/v1/authorization-epoch-helper\0" + private static let helperEpochPriorDomain = "eu.vaultsync.namespace/v1/helper-epoch-prior\0" + private static let helperEpochCurrentDomain = "eu.vaultsync.namespace/v1/helper-epoch-current\0" + private static let readme = """ + VaultSync Diagnostics + + EN: App-owned diagnostics infrastructure. It contains only opaque protocol + data. It is visible in file browsers, synchronized peers, backups, versions, + conflict copies, and deletion tombstones. Do not store notes here. + + DE: App-eigene Diagnose-Infrastruktur. Sie enthaelt nur undurchsichtige + Protokolldaten. Sie ist in Dateibrowsern, auf synchronisierten Geraeten, in + Backups, Versionen, Konfliktkopien und Loesch-Tombstones sichtbar. Keine + Notizen hier speichern. + + ES: Infraestructura de diagnostico propiedad de la aplicacion. Solo contiene + datos opacos del protocolo. Es visible en exploradores de archivos, pares + sincronizados, copias de seguridad, versiones, copias en conflicto y registros + de eliminacion. No guardes notas aqui. + + ZH-HANS: VaultSync 诊断基础设施,仅包含不透明的协议数据。它会显示在文件浏览器、 + 同步设备、备份、版本、冲突副本和删除记录中。请勿在此存储笔记。 + + """ + + struct RootManifest: Equatable, Sendable { + let message: Data + let namespaceID: Data + let rootDigest: Data + let manifestDigest: Data + } + + struct AuthorizationCandidate: Equatable, Sendable { + let message: Data + let installationBinding: Data + } + + static func makeEnablement( + record: DiagnosticsPairingRecord, + appKey: Curve25519.Signing.PrivateKey, + nonce: Data, + now: Date + ) throws -> Data { + guard nonce.count == 32, + appKey.publicKey.rawRepresentation == record.appPublicKey else { + throw DiagnosticsProtocolError.invalidMessage + } + let issued = try unixSeconds(now) + let body = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(1)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 9, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 10, value: .bytes(record.appPublicKey)), + DiagnosticsCBORField(label: 11, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 12, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 13, value: .bytes(record.helperPublicKey)), + DiagnosticsCBORField(label: 14, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 15, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 19, value: .bytes(nonce)), + DiagnosticsCBORField(label: 26, value: .unsigned(issued)), + DiagnosticsCBORField( + label: 27, + value: .unsigned(try DiagnosticsPairingProtocol.checkedAdding(issued, 300)) + ), + ]) + let bodyBytes = try DiagnosticsDeterministicCBOR.encode(body) + var input = Data(enablementDomain.utf8) + input.append(bodyBytes) + let signature = try appKey.signature(for: input) + guard case .map(var fields) = body else { throw DiagnosticsProtocolError.invalidMessage } + fields.append(DiagnosticsCBORField(label: 253, value: .bytes(signature))) + let encoded = try DiagnosticsDeterministicCBOR.encode(.map(fields)) + try validateEnablement(encoded, record: record) + return encoded + } + + static func validateRootManifest( + _ data: Data, + enablement: Data, + record: DiagnosticsPairingRecord + ) throws -> RootManifest { + try validateEnablement(enablement, record: record) + let value = try DiagnosticsDeterministicCBOR.decode(data) + let expected: [UInt64] = [1, 2, 3, 4, 5, 6, 7, 13, 14, 15, 19, 20, 28, 29, 255] + guard value.fields?.map(\.label) == expected, + value.text(for: 1) == capability, + value.unsigned(for: 2) == 1, + value.unsigned(for: 3) == 1, + value.unsigned(for: 4) == 2, + value.bytes(for: 5, count: 32) == record.homeserverBinding, + value.bytes(for: 6, count: 32) == record.folderBinding, + let namespaceID = value.bytes(for: 7, count: 32), namespaceID.contains(where: { $0 != 0 }), + value.bytes(for: 13, count: 32) == record.helperPublicKey, + value.bytes(for: 14, count: 32) == record.helperKeyID, + value.unsigned(for: 15) == record.helperEpoch, + let enablementValue = try? DiagnosticsDeterministicCBOR.decode(enablement), + value.bytes(for: 19, count: 32) == enablementValue.bytes(for: 19, count: 32), + value.bytes(for: 20, count: 32) == recordDigest(enablement), + let enablementIssued = enablementValue.unsigned(for: 26), + let enablementExpires = enablementValue.unsigned(for: 27), + let createdAt = value.unsigned(for: 28), + createdAt >= enablementIssued, createdAt <= enablementExpires, + value.bytes(for: 29, count: 32) == DiagnosticsCrypto.sha256(Data(readme.utf8)), + let signature = value.bytes(for: 255, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + var input = Data(rootDomain.utf8) + input.append(body) + let key = try Curve25519.Signing.PublicKey(rawRepresentation: record.helperPublicKey) + guard key.isValidSignature(signature, for: input) else { + throw DiagnosticsProtocolError.invalidMessage + } + let digest = recordDigest(data) + return RootManifest(message: data, namespaceID: namespaceID, rootDigest: digest, manifestDigest: digest) + } + + static func validateHelperEpochManifest( + _ data: Data, + rootData: Data, + priorManifestData: Data, + record: DiagnosticsPairingRecord + ) throws -> Data { + let value = try DiagnosticsDeterministicCBOR.decode(data) + let root = try DiagnosticsDeterministicCBOR.decode(rootData) + let prior = try DiagnosticsDeterministicCBOR.decode(priorManifestData) + guard let currentHelperEpoch = value.unsigned(for: 15), + let priorHelperEpoch = value.unsigned(for: 18), + priorHelperEpoch < UInt64.max else { + throw DiagnosticsProtocolError.invalidMessage + } + let expected: [UInt64] = [1, 2, 3, 4, 5, 6, 7, 13, 14, 15, 16, 17, 18, 21, 22, 28, 29, 254, 255] + guard value.fields?.map(\.label) == expected, + value.text(for: 1) == capability, + value.unsigned(for: 2) == 1, + value.unsigned(for: 3) == 1, + value.unsigned(for: 4) == 3, + value.bytes(for: 5, count: 32) == root.bytes(for: 5, count: 32), + value.bytes(for: 6, count: 32) == root.bytes(for: 6, count: 32), + value.bytes(for: 7, count: 32) == root.bytes(for: 7, count: 32), + value.bytes(for: 13, count: 32) == record.helperPublicKey, + value.bytes(for: 14, count: 32) == record.helperKeyID, + currentHelperEpoch == record.helperEpoch, + value.bytes(for: 16, count: 32) == prior.bytes(for: 13, count: 32), + value.bytes(for: 17, count: 32) == prior.bytes(for: 14, count: 32), + priorHelperEpoch == prior.unsigned(for: 15), + currentHelperEpoch == priorHelperEpoch + 1, + value.bytes(for: 21, count: 32) == recordDigest(rootData), + value.bytes(for: 22, count: 32) == recordDigest(priorManifestData), + (value.unsigned(for: 28) ?? 0) > 0, + value.bytes(for: 29, count: 32) == DiagnosticsCrypto.sha256(Data(readme.utf8)), + let priorPublic = value.bytes(for: 16, count: 32), + let priorSignature = value.bytes(for: 254, count: 64), + let currentSignature = value.bytes(for: 255, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + let priorBody = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [254, 255])) + var priorInput = Data(helperEpochPriorDomain.utf8) + priorInput.append(priorBody) + let priorKey = try Curve25519.Signing.PublicKey(rawRepresentation: priorPublic) + guard priorKey.isValidSignature(priorSignature, for: priorInput) else { + throw DiagnosticsProtocolError.invalidMessage + } + let currentBody = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + var currentInput = Data(helperEpochCurrentDomain.utf8) + currentInput.append(currentBody) + let currentKey = try Curve25519.Signing.PublicKey(rawRepresentation: record.helperPublicKey) + guard currentKey.isValidSignature(currentSignature, for: currentInput) else { + throw DiagnosticsProtocolError.invalidMessage + } + return recordDigest(data) + } + + static func makeInitialAuthorization( + record: DiagnosticsPairingRecord, + root: RootManifest, + appKey: Curve25519.Signing.PrivateKey, + nonce: Data, + now: Date + ) throws -> AuthorizationCandidate { + guard nonce.count == 32, nonce.contains(where: { $0 != 0 }), + appKey.publicKey.rawRepresentation == record.appPublicKey else { + throw DiagnosticsProtocolError.invalidMessage + } + let installation = installationBinding( + initialAppKeyID: record.appKeyID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding + ) + let issued = try unixSeconds(now) + let body = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(4)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(root.namespaceID)), + DiagnosticsCBORField(label: 8, value: .bytes(installation)), + DiagnosticsCBORField(label: 9, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 10, value: .bytes(record.appPublicKey)), + DiagnosticsCBORField(label: 11, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 12, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 13, value: .bytes(record.helperPublicKey)), + DiagnosticsCBORField(label: 14, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 15, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 21, value: .bytes(root.rootDigest)), + DiagnosticsCBORField(label: 23, value: .bytes(root.manifestDigest)), + DiagnosticsCBORField(label: 25, value: .bytes(record.currentCredentialStateDigest)), + DiagnosticsCBORField(label: 26, value: .unsigned(issued)), + DiagnosticsCBORField( + label: 27, + value: .unsigned(try DiagnosticsPairingProtocol.checkedAdding(issued, 300)) + ), + DiagnosticsCBORField(label: 30, value: .bytes(nonce)), + DiagnosticsCBORField(label: 31, value: .unsigned(1)), + ]) + let bodyBytes = try DiagnosticsDeterministicCBOR.encode(body) + var input = Data(authorizationAppDomain.utf8) + input.append(bodyBytes) + let signature = try appKey.signature(for: input) + guard case .map(var fields) = body else { throw DiagnosticsProtocolError.invalidMessage } + fields.append(DiagnosticsCBORField(label: 253, value: .bytes(signature))) + let encoded = try DiagnosticsDeterministicCBOR.encode(.map(fields)) + return AuthorizationCandidate(message: encoded, installationBinding: installation) + } + + static func makeAuthorizationEpoch( + record: DiagnosticsPairingRecord, + root: RootManifest, + priorAuthorizationDigest: Data, + appKey: Curve25519.Signing.PrivateKey, + nonce: Data, + now: Date + ) throws -> AuthorizationCandidate { + guard nonce.count == 32, nonce.contains(where: { $0 != 0 }), + appKey.publicKey.rawRepresentation == record.appPublicKey, + let initialAppKeyID = record.namespaceInitialAppKeyID, + initialAppKeyID.count == 32, + priorAuthorizationDigest.count == 32, + record.namespaceAuthorizationEpoch > 0, + record.namespaceAuthorizationEpoch < 9 else { + throw DiagnosticsProtocolError.invalidMessage + } + let installation = installationBinding( + initialAppKeyID: initialAppKeyID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding + ) + let issued = try unixSeconds(now) + let epoch = try DiagnosticsPairingProtocol.checkedAdding(record.namespaceAuthorizationEpoch, 1) + let body = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(5)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(root.namespaceID)), + DiagnosticsCBORField(label: 8, value: .bytes(installation)), + DiagnosticsCBORField(label: 9, value: .bytes(initialAppKeyID)), + DiagnosticsCBORField(label: 10, value: .bytes(record.appPublicKey)), + DiagnosticsCBORField(label: 11, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 12, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 13, value: .bytes(record.helperPublicKey)), + DiagnosticsCBORField(label: 14, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 15, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 21, value: .bytes(root.rootDigest)), + DiagnosticsCBORField(label: 23, value: .bytes(root.manifestDigest)), + DiagnosticsCBORField(label: 24, value: .bytes(priorAuthorizationDigest)), + DiagnosticsCBORField(label: 25, value: .bytes(record.currentCredentialStateDigest)), + DiagnosticsCBORField(label: 26, value: .unsigned(issued)), + DiagnosticsCBORField( + label: 27, + value: .unsigned(try DiagnosticsPairingProtocol.checkedAdding(issued, 300)) + ), + DiagnosticsCBORField(label: 30, value: .bytes(nonce)), + DiagnosticsCBORField(label: 31, value: .unsigned(epoch)), + ]) + let bodyBytes = try DiagnosticsDeterministicCBOR.encode(body) + var input = Data(authorizationEpochAppDomain.utf8) + input.append(bodyBytes) + let signature = try appKey.signature(for: input) + guard case .map(var fields) = body else { throw DiagnosticsProtocolError.invalidMessage } + fields.append(DiagnosticsCBORField(label: 253, value: .bytes(signature))) + return AuthorizationCandidate( + message: try DiagnosticsDeterministicCBOR.encode(.map(fields)), + installationBinding: installation + ) + } + + static func validateCompletedAuthorization( + _ data: Data, + candidate: AuthorizationCandidate, + record: DiagnosticsPairingRecord, + root: RootManifest + ) throws -> Data { + let value = try DiagnosticsDeterministicCBOR.decode(data) + let candidateValue = try DiagnosticsDeterministicCBOR.decode(candidate.message) + let expected: [UInt64] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21, 23, 25, 26, 27, 30, 31, 253, 255] + guard value.fields?.map(\.label) == expected, + value.removing(labels: [255]) == candidateValue, + value.bytes(for: 7, count: 32) == root.namespaceID, + value.bytes(for: 8, count: 32) == candidate.installationBinding, + value.bytes(for: 13, count: 32) == record.helperPublicKey, + let signature = value.bytes(for: 255, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + try validateAuthorizationCandidate( + candidate.message, + type: 4, + record: record, + root: root, + priorAuthorizationDigest: nil + ) + let helperBody = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + var helperInput = Data(authorizationHelperDomain.utf8) + helperInput.append(helperBody) + let helperKey = try Curve25519.Signing.PublicKey(rawRepresentation: record.helperPublicKey) + guard helperKey.isValidSignature(signature, for: helperInput) else { + throw DiagnosticsProtocolError.invalidMessage + } + return recordDigest(data) + } + + static func validateCompletedAuthorizationEpoch( + _ data: Data, + candidate: AuthorizationCandidate, + record: DiagnosticsPairingRecord, + root: RootManifest + ) throws -> Data { + let value = try DiagnosticsDeterministicCBOR.decode(data) + let candidateValue = try DiagnosticsDeterministicCBOR.decode(candidate.message) + let expected: [UInt64] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21, 23, 24, 25, 26, 27, 30, 31, 253, 255] + guard value.fields?.map(\.label) == expected, + value.removing(labels: [255]) == candidateValue, + value.unsigned(for: 4) == 5, + value.bytes(for: 7, count: 32) == root.namespaceID, + value.bytes(for: 8, count: 32) == candidate.installationBinding, + value.bytes(for: 13, count: 32) == record.helperPublicKey, + let signature = value.bytes(for: 255, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + try validateAuthorizationCandidate( + candidate.message, + type: 5, + record: record, + root: root, + priorAuthorizationDigest: record.namespaceAuthorizationDigest + ) + let helperBody = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + var helperInput = Data(authorizationEpochHelperDomain.utf8) + helperInput.append(helperBody) + let helperKey = try Curve25519.Signing.PublicKey(rawRepresentation: record.helperPublicKey) + guard helperKey.isValidSignature(signature, for: helperInput) else { + throw DiagnosticsProtocolError.invalidMessage + } + return recordDigest(data) + } + + static func authorizationRelativePath(installationBinding: Data) throws -> String { + guard installationBinding.count == 32 else { throw DiagnosticsProtocolError.invalidMessage } + return "installations/\(base32LowerNoPadding(installationBinding))/authorization.cbor" + } + + static func authorizationEpochRelativePath(installationBinding: Data, epoch: UInt64) throws -> String { + guard installationBinding.count == 32, epoch >= 2, epoch <= 9 else { + throw DiagnosticsProtocolError.invalidMessage + } + return "installations/\(base32LowerNoPadding(installationBinding))/authorization-epochs/\(epoch).authorization.cbor" + } + + static func recordDigest(_ data: Data) -> Data { + DiagnosticsCrypto.sha256(domain: recordDigestDomain, body: data) + } + + private static func validateEnablement(_ data: Data, record: DiagnosticsPairingRecord) throws { + let value = try DiagnosticsDeterministicCBOR.decode(data) + let expected: [UInt64] = [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15, 19, 26, 27, 253] + guard value.fields?.map(\.label) == expected, + value.text(for: 1) == capability, + value.unsigned(for: 2) == 1, + value.unsigned(for: 3) == 1, + value.unsigned(for: 4) == 1, + value.bytes(for: 5, count: 32) == record.homeserverBinding, + value.bytes(for: 6, count: 32) == record.folderBinding, + value.bytes(for: 9, count: 32) == record.appKeyID, + value.bytes(for: 10, count: 32) == record.appPublicKey, + value.bytes(for: 11, count: 32) == record.appKeyID, + value.unsigned(for: 12) == record.appEpoch, + value.bytes(for: 13, count: 32) == record.helperPublicKey, + value.bytes(for: 14, count: 32) == record.helperKeyID, + value.unsigned(for: 15) == record.helperEpoch, + value.bytes(for: 19, count: 32)?.contains(where: { $0 != 0 }) == true, + let issued = value.unsigned(for: 26), issued > 0, + let expires = value.unsigned(for: 27), + expires > issued, expires - issued <= 300, + let signature = value.bytes(for: 253, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [253])) + var input = Data(enablementDomain.utf8) + input.append(body) + let appKey = try Curve25519.Signing.PublicKey(rawRepresentation: record.appPublicKey) + guard appKey.isValidSignature(signature, for: input) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + private static func validateAuthorizationCandidate( + _ data: Data, + type: UInt64, + record: DiagnosticsPairingRecord, + root: RootManifest, + priorAuthorizationDigest: Data? + ) throws { + let value = try DiagnosticsDeterministicCBOR.decode(data) + let expected: [UInt64] = type == 4 + ? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21, 23, 25, 26, 27, 30, 31, 253] + : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21, 23, 24, 25, 26, 27, 30, 31, 253] + let initialAppKeyID: Data + let expectedEpoch: UInt64 + if type == 4 { + initialAppKeyID = record.appKeyID + expectedEpoch = 1 + } else { + guard type == 5, + let storedInitial = record.namespaceInitialAppKeyID, + let priorAuthorizationDigest, + priorAuthorizationDigest.count == 32, + value.bytes(for: 24, count: 32) == priorAuthorizationDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + initialAppKeyID = storedInitial + expectedEpoch = try DiagnosticsPairingProtocol.checkedAdding( + record.namespaceAuthorizationEpoch, + 1 + ) + } + let installation = installationBinding( + initialAppKeyID: initialAppKeyID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding + ) + guard value.fields?.map(\.label) == expected, + value.text(for: 1) == capability, + value.unsigned(for: 2) == 1, + value.unsigned(for: 3) == 1, + value.unsigned(for: 4) == type, + value.bytes(for: 5, count: 32) == record.homeserverBinding, + value.bytes(for: 6, count: 32) == record.folderBinding, + value.bytes(for: 7, count: 32) == root.namespaceID, + value.bytes(for: 8, count: 32) == installation, + value.bytes(for: 9, count: 32) == initialAppKeyID, + value.bytes(for: 10, count: 32) == record.appPublicKey, + value.bytes(for: 11, count: 32) == record.appKeyID, + value.unsigned(for: 12) == record.appEpoch, + value.bytes(for: 13, count: 32) == record.helperPublicKey, + value.bytes(for: 14, count: 32) == record.helperKeyID, + value.unsigned(for: 15) == record.helperEpoch, + value.bytes(for: 21, count: 32) == root.rootDigest, + value.bytes(for: 23, count: 32) == root.manifestDigest, + value.bytes(for: 25, count: 32) == record.currentCredentialStateDigest, + let issued = value.unsigned(for: 26), issued > 0, + let expires = value.unsigned(for: 27), + expires > issued, expires - issued <= 300, + value.bytes(for: 30, count: 32)?.contains(where: { $0 != 0 }) == true, + value.unsigned(for: 31) == expectedEpoch, + let signature = value.bytes(for: 253, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [253])) + let domain = type == 4 ? authorizationAppDomain : authorizationEpochAppDomain + var input = Data(domain.utf8) + input.append(body) + let key = try Curve25519.Signing.PublicKey(rawRepresentation: record.appPublicKey) + guard key.isValidSignature(signature, for: input) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + private static func installationBinding( + initialAppKeyID: Data, + homeserverBinding: Data, + folderBinding: Data + ) -> Data { + var body = initialAppKeyID + body.append(homeserverBinding) + body.append(folderBinding) + return DiagnosticsCrypto.sha256(domain: installationBindingDomain, body: body) + } + + private static func base32LowerNoPadding(_ data: Data) -> String { + let alphabet = Array("abcdefghijklmnopqrstuvwxyz234567".utf8) + var result: [UInt8] = [] + var buffer: UInt64 = 0 + var bits = 0 + for byte in data { + buffer = (buffer << 8) | UInt64(byte) + bits += 8 + while bits >= 5 { + bits -= 5 + result.append(alphabet[Int((buffer >> UInt64(bits)) & 0x1f)]) + buffer &= bits == 0 ? 0 : (1 << UInt64(bits)) - 1 + } + } + if bits > 0 { + result.append(alphabet[Int((buffer << UInt64(5 - bits)) & 0x1f)]) + } + return String(decoding: result, as: UTF8.self) + } + + private static func unixSeconds(_ date: Date) throws -> UInt64 { + let value = date.timeIntervalSince1970.rounded(.down) + guard value >= 0, value < Double(UInt64.max) else { + throw DiagnosticsProtocolError.invalidMessage + } + return UInt64(value) + } +} diff --git a/ios/VaultSync/Services/DiagnosticsCredentialStore.swift b/ios/VaultSync/Services/DiagnosticsCredentialStore.swift new file mode 100644 index 0000000..e0224e7 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsCredentialStore.swift @@ -0,0 +1,662 @@ +import CryptoKit +import Foundation +import Security +import os + +private let diagnosticsCredentialLogger = Logger( + subsystem: "eu.vaultsync.app", + category: "diagnostics-credentials" +) + +protocol DiagnosticsKeychainAccess { + func copyMatching(_ query: CFDictionary, result: inout AnyObject?) -> OSStatus + func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus + func add(_ attributes: CFDictionary) -> OSStatus + func delete(_ query: CFDictionary) -> OSStatus +} + +struct SystemDiagnosticsKeychainAccess: DiagnosticsKeychainAccess { + func copyMatching(_ query: CFDictionary, result: inout AnyObject?) -> OSStatus { + SecItemCopyMatching(query, &result) + } + + func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus { + SecItemUpdate(query, attributes) + } + + func add(_ attributes: CFDictionary) -> OSStatus { + SecItemAdd(attributes, nil) + } + + func delete(_ query: CFDictionary) -> OSStatus { + SecItemDelete(query) + } +} + +struct DiagnosticsPairingRecord: Codable, Equatable, Identifiable, Sendable { + enum State: String, Codable, Hashable, Sendable { + case requestPrepared + case acceptanceReceived + case finalizePrepared + case finalizeAcknowledged + case receiptPrepared + case readyAcknowledged + case activatePrepared + case abortPrepared + case active + case namespaceEnablementPrepared + case namespaceAwaitingOperator + case namespaceAuthorizationPrepared + case namespaceActive + case namespaceAuthorizationRefreshRequired + case namespaceAuthorizationRefreshPrepared + case lifecyclePending + case revocationPrepared + case revoked + } + + struct PendingLifecycle: Codable, Equatable, Sendable { + let kind: DiagnosticsPairingProtocol.TransitionKind + let transitionDigest: Data + var latestMessage: Data + var proposedAppSeed: Data? + var proposedHelperPublicKey: Data? + var proposedHelperEpoch: UInt64? + var proposedTLSSPKIPin: Data? + } + + struct LocalDeadline: Codable, Equatable, Sendable { + let createdWallSeconds: UInt64 + let createdContinuousSeconds: TimeInterval + let expiresContinuousSeconds: TimeInterval + } + + let id: String + let homeserverDeviceID: String + let folderID: String + let endpointHost: String + let endpointPort: UInt16 + var tlsSPKIPin: Data + var helperPublicKey: Data + var helperKeyID: Data + let homeserverBinding: Data + let folderBinding: Data + var appSeed: Data + var appPublicKey: Data + var appKeyID: Data + var appEpoch: UInt64 + var helperEpoch: UInt64 + var currentCredentialStateDigest: Data + var state: State + var hardExpiry: UInt64 + var localDeadline: LocalDeadline? + var lastOutgoing: Data + var lastIncoming: Data? + var transcriptFingerprint: String? + var namespaceID: Data? + var namespaceInitialAppKeyID: Data? + var namespaceEnablement: Data? + var namespaceRootDigest: Data? + var namespaceManifestDigest: Data? + var namespaceManifestEpoch: UInt64? + var namespaceAuthorizationDigest: Data? + var namespaceAuthorizationEpoch: UInt64 + var pendingLifecycle: PendingLifecycle? + + static func identifier(appKeyID: Data, folderBinding: Data) -> String { + var body = appKeyID + body.append(folderBinding) + return DiagnosticsCrypto.base64URLEncode( + DiagnosticsCrypto.sha256(domain: "eu.vaultsync.app/diagnostics-record/v1\0", body: body) + ) + } +} + +struct DiagnosticsInstallationCredential: Sendable { + let privateKey: Curve25519.Signing.PrivateKey + let markerDigest: Data +} + +final class DiagnosticsCredentialStore: @unchecked Sendable { + static let service = "eu.vaultsync.app.diagnostics.v1" + private static let installationAccount = "installation-key-v1" + private static let recordPrefix = "record-v1." + private static let formatVersion = 1 + + private struct InstallationEnvelope: Codable { + let formatVersion: Int + let seed: Data + let markerDigest: Data + } + + private let fileManager: FileManager + private let applicationSupportURL: URL + private let service: String + private let keychain: any DiagnosticsKeychainAccess + + init( + fileManager: FileManager = .default, + applicationSupportURL: URL? = nil, + service: String = DiagnosticsCredentialStore.service, + keychain: any DiagnosticsKeychainAccess = SystemDiagnosticsKeychainAccess() + ) { + self.fileManager = fileManager + self.applicationSupportURL = applicationSupportURL ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + self.service = service + self.keychain = keychain + } + + /// Read-only inspection. It never creates an installation marker, key, or + /// pairing record, which keeps an existing-user upgrade mutation-free. + func inspection() throws -> (hasMarker: Bool, hasCredential: Bool, records: [DiagnosticsPairingRecord]) { + let marker = try readMarker() + let encoded = try read(account: Self.installationAccount) + let records = try loadRecords() + switch (marker, encoded) { + case (nil, nil): + guard records.isEmpty else { throw DiagnosticsProtocolError.recoveryRequired } + return (false, false, []) + case let (marker?, encoded?): + _ = try decodeInstallationCredential(marker: marker, encoded: encoded) + return (true, true, records) + case (.some, nil), (nil, .some): + throw DiagnosticsProtocolError.recoveryRequired + } + } + + /// Called only from the explicit pairing action. + func installationCredential() throws -> DiagnosticsInstallationCredential { + let marker = try readMarker() + let encoded = try read(account: Self.installationAccount) + switch (marker, encoded) { + case (nil, nil): + let newMarker = try DiagnosticsCrypto.randomBytes(count: 32) + let seed = try DiagnosticsCrypto.randomBytes(count: 32) + let digest = DiagnosticsCrypto.sha256(newMarker) + let envelope = InstallationEnvelope( + formatVersion: Self.formatVersion, + seed: seed, + markerDigest: digest + ) + // The container marker is committed before the Keychain item. A + // crash between them yields recoveryRequired on the next attempt; + // it never silently adopts a surviving credential. + try writeMarker(newMarker) + do { + try write(account: Self.installationAccount, data: try JSONEncoder().encode(envelope)) + } catch { + try? removeMarker() + throw error + } + return DiagnosticsInstallationCredential( + privateKey: try Curve25519.Signing.PrivateKey(rawRepresentation: seed), + markerDigest: digest + ) + case let (marker?, encoded?): + return try decodeInstallationCredential(marker: marker, encoded: encoded) + case (.some, nil), (nil, .some): + throw DiagnosticsProtocolError.recoveryRequired + } + } + + /// Durably selects the next installation-wide app key before any scoped + /// authorization starts its D022 transition. Authorizations still advance + /// independently, but every lagging record reuses this exact seed instead + /// of forking the installation identity into per-folder keys. + func advanceInstallationAppKey( + expected: Curve25519.Signing.PrivateKey, + proposed: Curve25519.Signing.PrivateKey + ) throws { + let marker = try readMarker() + let encoded = try read(account: Self.installationAccount) + guard let marker, let encoded else { + throw DiagnosticsProtocolError.recoveryRequired + } + let current = try decodeInstallationCredential(marker: marker, encoded: encoded) + let expectedSeed = expected.rawRepresentation + let proposedSeed = proposed.rawRepresentation + guard current.privateKey.rawRepresentation == expectedSeed, + proposedSeed != expectedSeed, + proposed.publicKey.rawRepresentation != expected.publicKey.rawRepresentation else { + throw DiagnosticsProtocolError.conflict + } + let envelope = InstallationEnvelope( + formatVersion: Self.formatVersion, + seed: proposedSeed, + markerDigest: current.markerDigest + ) + try write(account: Self.installationAccount, data: try JSONEncoder().encode(envelope)) + } + + func loadRecords() throws -> [DiagnosticsPairingRecord] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrSynchronizable as String: kCFBooleanFalse!, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + var result: AnyObject? + let status = keychain.copyMatching(query as CFDictionary, result: &result) + if status == errSecItemNotFound { return [] } + try check(status) + guard let entries = result as? [[String: Any]] else { + throw DiagnosticsProtocolError.protectedDataUnavailable + } + var records: [DiagnosticsPairingRecord] = [] + for entry in entries { + guard let account = entry[kSecAttrAccount as String] as? String, + account.hasPrefix(Self.recordPrefix), + let data = entry[kSecValueData as String] as? Data else { continue } + do { + guard data.count <= 256 * 1024 else { + throw DiagnosticsProtocolError.invalidMessage + } + let record = try JSONDecoder().decode(DiagnosticsPairingRecord.self, from: data) + try validate(record, account: account) + records.append(record) + } catch { + diagnosticsCredentialLogger.error("Diagnostics credential record validation failed") + throw DiagnosticsProtocolError.recoveryRequired + } + } + return records.sorted { $0.id < $1.id } + } + + private func decodeInstallationCredential( + marker: Data, + encoded: Data + ) throws -> DiagnosticsInstallationCredential { + do { + guard marker.count == 32, encoded.count <= 4 * 1024 else { + throw DiagnosticsProtocolError.recoveryRequired + } + let envelope = try JSONDecoder().decode(InstallationEnvelope.self, from: encoded) + guard envelope.formatVersion == Self.formatVersion, + envelope.seed.count == 32, + envelope.markerDigest.count == 32, + envelope.markerDigest == DiagnosticsCrypto.sha256(marker) else { + throw DiagnosticsProtocolError.recoveryRequired + } + return DiagnosticsInstallationCredential( + privateKey: try Curve25519.Signing.PrivateKey(rawRepresentation: envelope.seed), + markerDigest: envelope.markerDigest + ) + } catch { + throw DiagnosticsProtocolError.recoveryRequired + } + } + + private func validate(_ record: DiagnosticsPairingRecord, account: String) throws { + guard account == Self.recordPrefix + record.id, + let identifier = try? DiagnosticsCrypto.base64URLDecode(record.id), + identifier.count == 32, + record.homeserverDeviceID.utf8.count <= 63, + (try? DiagnosticsSyncthingBinding.rawDeviceID(record.homeserverDeviceID)) != nil, + record.folderID.utf8.count <= 255, + (try? DiagnosticsSyncthingBinding.folderDigest(record.folderID)) != nil, + DiagnosticsPairingProtocol.validEndpointHost(record.endpointHost), + record.endpointPort > 0, + validNonzero32(record.tlsSPKIPin), + validNonzero32(record.helperPublicKey), + record.helperKeyID == DiagnosticsCrypto.keyID(publicKey: record.helperPublicKey), + validNonzero32(record.homeserverBinding), + validNonzero32(record.folderBinding), + record.appSeed.count == 32, + validNonzero32(record.appPublicKey), + record.appKeyID == DiagnosticsCrypto.keyID(publicKey: record.appPublicKey), + record.appEpoch > 0, + record.helperEpoch > 0, + validNonzero32(record.currentCredentialStateDigest), + record.hardExpiry > 0, + validControlMessage(record.lastOutgoing), + record.lastIncoming == nil || validControlMessage(record.lastIncoming!), + record.namespaceAuthorizationEpoch <= 9 else { + throw DiagnosticsProtocolError.invalidMessage + } + let appKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + guard appKey.publicKey.rawRepresentation == record.appPublicKey else { + throw DiagnosticsProtocolError.invalidMessage + } + if let deadline = record.localDeadline { + let maximumExpiry = deadline.createdContinuousSeconds + + TimeInterval(DiagnosticsPairingProtocol.maximumLifetime) + guard deadline.createdWallSeconds > 0, + deadline.createdContinuousSeconds.isFinite, + deadline.createdContinuousSeconds >= 0, + deadline.expiresContinuousSeconds.isFinite, + deadline.expiresContinuousSeconds > deadline.createdContinuousSeconds, + maximumExpiry.isFinite, + deadline.expiresContinuousSeconds <= maximumExpiry else { + throw DiagnosticsProtocolError.invalidMessage + } + } + let deadlineStates: Set = [ + .requestPrepared, .acceptanceReceived, .finalizePrepared, + .finalizeAcknowledged, .receiptPrepared, .readyAcknowledged, + .activatePrepared, .abortPrepared, .namespaceEnablementPrepared, + .namespaceAwaitingOperator, .namespaceAuthorizationPrepared, + .namespaceAuthorizationRefreshPrepared, .lifecyclePending, + .revocationPrepared, + ] + guard deadlineStates.contains(record.state) == (record.localDeadline != nil) else { + throw DiagnosticsProtocolError.invalidMessage + } + if let fingerprint = record.transcriptFingerprint { + guard fingerprint.utf8.count == 12, + fingerprint.utf8.allSatisfy({ + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x41 && $0 <= 0x46) + }) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + for value in [record.namespaceID, record.namespaceInitialAppKeyID, + record.namespaceRootDigest, record.namespaceManifestDigest, + record.namespaceAuthorizationDigest] { + guard value == nil || validNonzero32(value!) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + guard record.namespaceEnablement == nil || validControlMessage(record.namespaceEnablement!) else { + throw DiagnosticsProtocolError.invalidMessage + } + if record.namespaceAuthorizationEpoch > 0 { + guard record.namespaceID != nil, + record.namespaceInitialAppKeyID != nil, + record.namespaceEnablement != nil, + record.namespaceRootDigest != nil, + record.namespaceManifestDigest != nil, + record.namespaceManifestEpoch != nil, + record.namespaceAuthorizationDigest != nil else { + throw DiagnosticsProtocolError.invalidMessage + } + } + try validateNamespaceState(record) + if let pending = record.pendingLifecycle { + let latest = try DiagnosticsPairingProtocol.decode(pending.latestMessage) + guard record.state == .lifecyclePending, + validNonzero32(pending.transitionDigest), + pending.latestMessage == record.lastOutgoing || + pending.latestMessage == record.lastIncoming, + latest.value.bytes(for: 5, count: 32) == record.homeserverBinding, + latest.value.bytes(for: 6, count: 32) == record.folderBinding, + latest.value.bytes(for: 7, count: 32) == record.appPublicKey, + latest.value.bytes(for: 8, count: 32) == record.appKeyID, + latest.value.bytes(for: 11, count: 32) == record.helperPublicKey, + latest.value.bytes(for: 12, count: 32) == record.helperKeyID, + latest.value.bytes(for: 15, count: 32) == record.tlsSPKIPin, + latest.value.unsigned(for: 17) == record.appEpoch, + latest.value.unsigned(for: 19) == record.helperEpoch, + latest.value.bytes(for: 26, count: 32) == record.currentCredentialStateDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + switch pending.kind { + case .appKey: + guard let proposedSeed = pending.proposedAppSeed, + let proposedKey = try? Curve25519.Signing.PrivateKey( + rawRepresentation: proposedSeed + ), + latest.value.bytes(for: 9, count: 32) == proposedKey.publicKey.rawRepresentation, + latest.value.bytes(for: 10, count: 32) == DiagnosticsCrypto.keyID( + publicKey: proposedKey.publicKey.rawRepresentation + ), + record.appEpoch < UInt64.max, + latest.value.unsigned(for: 18) == record.appEpoch + 1, + [.appKeyRotationRequest, .appKeyRotationNewProof, + .appKeyRotationAccept, .lifecycleFinalize, + .lifecycleActiveAck, .lifecycleAbort].contains(latest.type), + pending.proposedHelperPublicKey == nil, + pending.proposedHelperEpoch == nil, + pending.proposedTLSSPKIPin == nil else { + throw DiagnosticsProtocolError.invalidMessage + } + case .helperKey: + guard let proposedPublic = pending.proposedHelperPublicKey, + validNonzero32(proposedPublic), + record.helperEpoch < UInt64.max, + pending.proposedHelperEpoch == record.helperEpoch + 1, + latest.value.bytes(for: 13, count: 32) == proposedPublic, + latest.value.bytes(for: 14, count: 32) == DiagnosticsCrypto.keyID( + publicKey: proposedPublic + ), + latest.value.unsigned(for: 20) == pending.proposedHelperEpoch, + [.helperKeyRotationConfirm, .lifecycleFinalize, + .lifecycleActiveAck, .lifecycleAbort].contains(latest.type), + pending.proposedAppSeed == nil, + pending.proposedTLSSPKIPin == nil else { + throw DiagnosticsProtocolError.invalidMessage + } + case .tlsPin: + guard let proposedPin = pending.proposedTLSSPKIPin, + validNonzero32(proposedPin), + latest.value.bytes(for: 16, count: 32) == proposedPin, + [.tlsPinRotationConfirm, .lifecycleFinalize, + .lifecycleActiveAck, .lifecycleAbort].contains(latest.type), + pending.proposedAppSeed == nil, + pending.proposedHelperPublicKey == nil, + pending.proposedHelperEpoch == nil else { + throw DiagnosticsProtocolError.invalidMessage + } + } + if [.lifecycleFinalize, .lifecycleActiveAck, .lifecycleAbort].contains(latest.type) { + guard latest.value.unsigned(for: 29) == pending.kind.rawValue, + latest.value.bytes(for: 28, count: 32) == pending.transitionDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + } + } else if record.state == .lifecyclePending { + throw DiagnosticsProtocolError.invalidMessage + } + } + + private func validateNamespaceState(_ record: DiagnosticsPairingRecord) throws { + let namespaceValues = [ + record.namespaceID, + record.namespaceInitialAppKeyID, + record.namespaceEnablement, + record.namespaceRootDigest, + record.namespaceManifestDigest, + record.namespaceAuthorizationDigest, + ] + let hasAnyNamespaceValue = namespaceValues.contains(where: { $0 != nil }) || + record.namespaceManifestEpoch != nil + let hasCompleteAuthorization = namespaceValues.allSatisfy { $0 != nil } && + record.namespaceManifestEpoch != nil && + record.namespaceAuthorizationEpoch > 0 + + if let manifestEpoch = record.namespaceManifestEpoch { + guard manifestEpoch > 0, manifestEpoch <= record.helperEpoch else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + switch record.state { + case .requestPrepared, .acceptanceReceived, .finalizePrepared, + .finalizeAcknowledged, .receiptPrepared, .readyAcknowledged, + .activatePrepared, .abortPrepared, .active: + guard !hasAnyNamespaceValue, record.namespaceAuthorizationEpoch == 0 else { + throw DiagnosticsProtocolError.invalidMessage + } + case .namespaceEnablementPrepared, .namespaceAwaitingOperator: + guard record.namespaceEnablement != nil, + record.namespaceID == nil, + record.namespaceInitialAppKeyID == nil, + record.namespaceRootDigest == nil, + record.namespaceManifestDigest == nil, + record.namespaceManifestEpoch == nil, + record.namespaceAuthorizationDigest == nil, + record.namespaceAuthorizationEpoch == 0 else { + throw DiagnosticsProtocolError.invalidMessage + } + case .namespaceAuthorizationPrepared: + guard record.namespaceEnablement != nil, + record.namespaceID != nil, + record.namespaceInitialAppKeyID == nil, + record.namespaceRootDigest != nil, + record.namespaceManifestDigest != nil, + record.namespaceManifestEpoch != nil, + record.namespaceAuthorizationDigest == nil, + record.namespaceAuthorizationEpoch == 0 else { + throw DiagnosticsProtocolError.invalidMessage + } + case .namespaceActive, .namespaceAuthorizationRefreshRequired, + .namespaceAuthorizationRefreshPrepared: + guard hasCompleteAuthorization else { + throw DiagnosticsProtocolError.invalidMessage + } + case .lifecyclePending, .revocationPrepared, .revoked: + guard (!hasAnyNamespaceValue && record.namespaceAuthorizationEpoch == 0) || + hasCompleteAuthorization else { + throw DiagnosticsProtocolError.invalidMessage + } + } + } + + private func validNonzero32(_ value: Data) -> Bool { + value.count == 32 && value.contains(where: { $0 != 0 }) + } + + private func validControlMessage(_ value: Data) -> Bool { + !value.isEmpty && value.count <= DiagnosticsDeterministicCBOR.maximumMessageBytes && + (try? DiagnosticsDeterministicCBOR.decode(value)) != nil + } + + func save(_ record: DiagnosticsPairingRecord) throws { + let account = Self.recordPrefix + record.id + try validate(record, account: account) + try write( + account: account, + data: try JSONEncoder().encode(record) + ) + } + + func delete(_ record: DiagnosticsPairingRecord) throws { + try delete(account: Self.recordPrefix + record.id) + } + + /// Explicit lost-container/lost-key recovery. It removes only this app's + /// local diagnostics credentials; the UI requires a new QR pairing and + /// tells the operator to revoke the old helper authorization separately. + func resetForExplicitRepair() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrSynchronizable as String: kCFBooleanFalse!, + ] + let status = keychain.delete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + try check(status) + } + try removeMarker() + } + + private func read(account: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kCFBooleanFalse!, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = keychain.copyMatching(query as CFDictionary, result: &result) + if status == errSecItemNotFound { return nil } + try check(status) + guard let data = result as? Data else { + throw DiagnosticsProtocolError.protectedDataUnavailable + } + return data + } + + private func write(account: String, data: Data) throws { + let identity: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kCFBooleanFalse!, + ] + let update: [String: Any] = [kSecValueData as String: data] + let updateStatus = keychain.update(identity as CFDictionary, attributes: update as CFDictionary) + if updateStatus == errSecSuccess { return } + if updateStatus != errSecItemNotFound { try check(updateStatus) } + var insertion = identity + insertion[kSecValueData as String] = data + insertion[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + try check(keychain.add(insertion as CFDictionary)) + } + + private func delete(account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kCFBooleanFalse!, + ] + let status = keychain.delete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + try check(status) + } + } + + private func check(_ status: OSStatus) throws { + guard status == errSecSuccess else { + diagnosticsCredentialLogger.error("Diagnostics Keychain operation failed with status category \(status)") + switch status { + case errSecInteractionNotAllowed, errSecNotAvailable, errSecAuthFailed: + throw DiagnosticsProtocolError.protectedDataUnavailable + default: + throw DiagnosticsProtocolError.unavailable + } + } + } + + private var markerURL: URL { + applicationSupportURL + .appendingPathComponent("ControlledDiagnostics", isDirectory: true) + .appendingPathComponent("installation-marker-v1", isDirectory: false) + } + + private func readMarker() throws -> Data? { + do { + let data = try Data(contentsOf: markerURL, options: [.uncached]) + guard data.count == 32 else { throw DiagnosticsProtocolError.recoveryRequired } + return data + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + return nil + } catch let error as DiagnosticsProtocolError { + throw error + } catch { + throw DiagnosticsProtocolError.protectedDataUnavailable + } + } + + private func writeMarker(_ marker: Data) throws { + guard marker.count == 32 else { throw DiagnosticsProtocolError.invalidMessage } + let directory = markerURL.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.complete] + ) + try marker.write(to: markerURL, options: [.atomic, .completeFileProtection]) + } catch { + throw DiagnosticsProtocolError.protectedDataUnavailable + } + } + + private func removeMarker() throws { + do { + try fileManager.removeItem(at: markerURL) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + return + } catch { + throw DiagnosticsProtocolError.protectedDataUnavailable + } + } +} diff --git a/ios/VaultSync/Services/DiagnosticsPairingController.swift b/ios/VaultSync/Services/DiagnosticsPairingController.swift new file mode 100644 index 0000000..8b3b238 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsPairingController.swift @@ -0,0 +1,1527 @@ +import CryptoKit +import Darwin +import Foundation +import Observation + +@MainActor +@Observable +final class DiagnosticsPairingController { + enum CapabilityState: String, Sendable { + case notChecked + case checking + case available + case unavailable + case unsupported + } + + enum Notice: Equatable, Sendable { + case none + case fingerprint(recordID: String, value: String) + case pairingActive(recordID: String) + case operatorNamespaceAction(recordID: String) + case namespaceAuthorizationPending(recordID: String) + case namespaceActive(recordID: String) + case recoveryRequired + } + + typealias TransportFactory = @Sendable (String, UInt16, Data) throws -> any DiagnosticsTransporting + + private let credentialStore: DiagnosticsCredentialStore + private let transportFactory: TransportFactory + private let now: @Sendable () -> Date + private let continuousNow: @Sendable () -> TimeInterval + private var capabilityValidUntil: [String: TimeInterval] = [:] + + private(set) var records: [DiagnosticsPairingRecord] = [] + private(set) var capabilityStates: [String: CapabilityState] = [:] + private(set) var notice: Notice = .none + private(set) var lastError: DiagnosticsProtocolError? + private(set) var isBusy = false + private(set) var hasInstallationMarker = false + private(set) var hasInstallationCredential = false + + init( + credentialStore: DiagnosticsCredentialStore = DiagnosticsCredentialStore(), + transportFactory: @escaping TransportFactory = { host, port, pin in + try DiagnosticsPinnedTransport(host: host, port: port, pin: pin) + }, + now: @escaping @Sendable () -> Date = Date.init, + continuousNow: @escaping @Sendable () -> TimeInterval = DiagnosticsContinuousClock.seconds + ) { + self.credentialStore = credentialStore + self.transportFactory = transportFactory + self.now = now + self.continuousNow = continuousNow + } + + func refresh() { + do { + let inspection = try credentialStore.inspection() + hasInstallationMarker = inspection.hasMarker + hasInstallationCredential = inspection.hasCredential + records = inspection.records + capabilityStates = [:] + capabilityValidUntil = [:] + if inspection.hasMarker != inspection.hasCredential { + notice = .recoveryRequired + } + lastError = nil + } catch let error as DiagnosticsProtocolError { + records = [] + capabilityStates = [:] + capabilityValidUntil = [:] + hasInstallationMarker = false + hasInstallationCredential = false + lastError = error + if error == .recoveryRequired { notice = .recoveryRequired } + } catch { + records = [] + capabilityStates = [:] + capabilityValidUntil = [:] + hasInstallationMarker = false + hasInstallationCredential = false + lastError = .unavailable + } + } + + func beginPairing(qr: String, homeserverDeviceID: String, folderID: String) async { + await perform { + let invitation = try DiagnosticsPairingProtocol.decodeQR(qr, now: now()) + let credential = try credentialStore.installationCredential() + let appKey = credential.privateKey + let request = try DiagnosticsPairingProtocol.makeAppRequest( + invitation: invitation, + appPrivateKey: appKey, + selectedDeviceID: homeserverDeviceID, + selectedFolderID: folderID, + appNonce: try DiagnosticsCrypto.randomBytes(count: 32) + ) + guard let endpointHost = invitation.value.text(for: 6), + let endpointPort = invitation.value.unsigned(for: 7), endpointPort <= UInt16.max, + let tlsPin = invitation.value.bytes(for: 8, count: 32), + let helperPublic = invitation.value.bytes(for: 9, count: 32), + let helperKeyID = invitation.value.bytes(for: 10, count: 32), + let homeserverBinding = invitation.value.bytes(for: 11, count: 32), + let folderBinding = invitation.value.bytes(for: 12, count: 32), + let hardExpiry = invitation.value.unsigned(for: 16) else { + throw DiagnosticsProtocolError.invalidMessage + } + let appPublic = appKey.publicKey.rawRepresentation + let appKeyID = DiagnosticsCrypto.keyID(publicKey: appPublic) + let identifier = DiagnosticsPairingRecord.identifier(appKeyID: appKeyID, folderBinding: folderBinding) + guard !records.contains(where: { + $0.homeserverDeviceID == homeserverDeviceID && $0.folderID == folderID && $0.state != .revoked + }) else { + throw DiagnosticsProtocolError.conflict + } + var record = DiagnosticsPairingRecord( + id: identifier, + homeserverDeviceID: homeserverDeviceID, + folderID: folderID, + endpointHost: endpointHost, + endpointPort: UInt16(endpointPort), + tlsSPKIPin: tlsPin, + helperPublicKey: helperPublic, + helperKeyID: helperKeyID, + homeserverBinding: homeserverBinding, + folderBinding: folderBinding, + appSeed: appKey.rawRepresentation, + appPublicKey: appPublic, + appKeyID: appKeyID, + appEpoch: 1, + helperEpoch: invitation.value.unsigned(for: 24)!, + currentCredentialStateDigest: try request.digest(), + state: .requestPrepared, + hardExpiry: hardExpiry, + localDeadline: try makeLocalDeadline(expiresAt: hardExpiry), + lastOutgoing: request.canonical, + lastIncoming: nil, + transcriptFingerprint: nil, + namespaceID: nil, + namespaceInitialAppKeyID: nil, + namespaceEnablement: nil, + namespaceRootDigest: nil, + namespaceManifestDigest: nil, + namespaceManifestEpoch: nil, + namespaceAuthorizationDigest: nil, + namespaceAuthorizationEpoch: 0, + pendingLifecycle: nil + ) + try persist(record) + try await sendPreparedBootstrap(&record) + } + } + + func retryPreparedPairing(recordID: String) async { + await perform { + var record = try requiredRecord(recordID) + try requireLocalDeadline(record) + switch record.state { + case .requestPrepared, .finalizePrepared, .receiptPrepared, .activatePrepared: + try await sendPreparedBootstrap(&record) + default: + throw DiagnosticsProtocolError.conflict + } + } + } + + func confirmFingerprintAndActivate(recordID: String) async { + await perform { + var record = try requiredRecord(recordID) + try requireLocalDeadline(record) + guard record.state == .acceptanceReceived || + record.state == .finalizeAcknowledged || + record.state == .readyAcknowledged else { + throw DiagnosticsProtocolError.conflict + } + while record.state != .active { + switch record.state { + case .acceptanceReceived: + try prepareBootstrap(&record, type: .finalize, preparedState: .finalizePrepared) + case .finalizeAcknowledged: + try prepareBootstrap(&record, type: .receipt, preparedState: .receiptPrepared) + case .readyAcknowledged: + try prepareBootstrap(&record, type: .activate, preparedState: .activatePrepared) + default: + throw DiagnosticsProtocolError.conflict + } + try await sendPreparedBootstrap(&record) + } + notice = .pairingActive(recordID: record.id) + } + } + + func cancelPendingPairing(recordID: String) async { + await perform { + var record = try requiredRecord(recordID) + try requireLocalDeadline(record) + switch record.state { + case .requestPrepared, .finalizePrepared, .receiptPrepared: + try await sendPreparedBootstrap(&record) + case .acceptanceReceived, .finalizeAcknowledged, .readyAcknowledged: + break + case .abortPrepared: + try await sendPreparedAbort(&record) + return + default: + // Once type 7 may have reached the helper, only authenticated + // revocation can safely retire the authorization. + throw DiagnosticsProtocolError.conflict + } + guard let incoming = record.lastIncoming else { + throw DiagnosticsProtocolError.invalidMessage + } + let prior = try DiagnosticsPairingProtocol.decode(incoming) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let abort = try DiagnosticsPairingProtocol.makeBootstrapTransition( + prior: prior, + type: .abort, + appPrivateKey: key, + now: now(), + hardExpiry: record.hardExpiry + ) + record.lastOutgoing = abort.canonical + record.state = .abortPrepared + try persist(record) + try await sendPreparedAbort(&record) + } + } + + func discardExpiredPendingPairing(recordID: String) { + do { + let record = try requiredRecord(recordID) + guard canDiscardExpiredPairing(record) else { + throw DiagnosticsProtocolError.conflict + } + try credentialStore.delete(record) + records.removeAll { $0.id == record.id } + capabilityStates.removeValue(forKey: record.id) + capabilityValidUntil.removeValue(forKey: record.id) + notice = .none + lastError = nil + } catch let error as DiagnosticsProtocolError { + lastError = error + } catch { + lastError = .unavailable + } + } + + func canDiscardExpiredPairing(_ record: DiagnosticsPairingRecord) -> Bool { + let states: Set = [ + .requestPrepared, .acceptanceReceived, .finalizePrepared, + .finalizeAcknowledged, .receiptPrepared, .readyAcknowledged, + .abortPrepared, + ] + guard states.contains(record.state), + record.hardExpiry <= UInt64.max - DiagnosticsPairingProtocol.maximumClockSkew else { + return false + } + if let deadline = record.localDeadline, localDeadlineExpired(deadline) { + return true + } + let seconds = now().timeIntervalSince1970.rounded(.down) + guard seconds >= 0, seconds < Double(UInt64.max) else { return false } + return UInt64(seconds) > record.hardExpiry + DiagnosticsPairingProtocol.maximumClockSkew + } + + func checkCapability(recordID: String) async { + guard !isBusy else { return } + capabilityValidUntil.removeValue(forKey: recordID) + capabilityStates[recordID] = .checking + await perform { + let record = try requiredActiveRecord(recordID) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let query = try DiagnosticsCapabilityProtocol.makeQuery( + record: record, + appKey: key, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + let transport = try makeTransport(record) + do { + guard let response = try await transport.post( + path: DiagnosticsCapabilityProtocol.path, + body: query.message, + responseBody: true + ) else { + throw DiagnosticsProtocolError.invalidMessage + } + let expires = try DiagnosticsCapabilityProtocol.validateResponse( + response, + query: query, + record: record, + now: now() + ) + capabilityValidUntil[recordID] = try makeContinuousDeadline( + expiresAt: expires, + maximumLifetime: 120 + ) + capabilityStates[recordID] = .available + } catch let error as DiagnosticsProtocolError { + capabilityValidUntil.removeValue(forKey: recordID) + switch error { + case .invalidMessage, .unsupported, .conflict: + capabilityStates[recordID] = .unsupported + default: + capabilityStates[recordID] = .unavailable + } + throw error + } + } + if capabilityStates[recordID] == .checking { + capabilityStates[recordID] = .unavailable + } + } + + func requestNamespaceEnablement(recordID: String) async { + await perform { + var record = try requiredActiveRecord(recordID) + guard capabilityStates[recordID] == .available, + record.namespaceAuthorizationEpoch == 0 else { + throw DiagnosticsProtocolError.unsupported + } + let currentContinuous = continuousNow() + guard let capabilityExpiry = capabilityValidUntil[recordID], + currentContinuous.isFinite, + currentContinuous >= 0, + currentContinuous < capabilityExpiry else { + invalidateCapability(recordID) + throw DiagnosticsProtocolError.unavailable + } + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let enablement = try DiagnosticsNamespaceProtocol.makeEnablement( + record: record, + appKey: key, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + record.namespaceEnablement = enablement + record.localDeadline = try makeLocalDeadline( + expiresAt: try messageExpiry(enablement, label: 27) + ) + record.lastOutgoing = enablement + record.lastIncoming = nil + record.state = .namespaceEnablementPrepared + try persist(record) + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsNamespaceProtocol.enablementPath, + body: enablement, + responseBody: false + ) + record.state = .namespaceAwaitingOperator + try persist(record) + notice = .operatorNamespaceAction(recordID: record.id) + } + } + + /// This advances exactly one explicit namespace step. It never polls and + /// never creates or adopts a directory on the app side. + func continueNamespace(recordID: String, currentFolderPath: String) async { + await perform { + var record = try requiredRecord(recordID) + switch record.state { + case .namespaceEnablementPrepared: + try requireLocalDeadline(record) + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsNamespaceProtocol.enablementPath, + body: record.lastOutgoing, + responseBody: false + ) + record.state = .namespaceAwaitingOperator + try persist(record) + notice = .operatorNamespaceAction(recordID: record.id) + case .namespaceAwaitingOperator: + guard let enablement = record.namespaceEnablement else { + throw DiagnosticsProtocolError.invalidMessage + } + let rootData = try DiagnosticsNamespaceFileReader.read( + folderPath: currentFolderPath, + components: [DiagnosticsNamespaceProtocol.rootName, DiagnosticsNamespaceProtocol.rootManifestName] + ) + let root = try DiagnosticsNamespaceProtocol.validateRootManifest( + rootData, + enablement: enablement, + record: record + ) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let candidate = try DiagnosticsNamespaceProtocol.makeInitialAuthorization( + record: record, + root: root, + appKey: key, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + record.namespaceID = root.namespaceID + record.namespaceRootDigest = root.rootDigest + record.namespaceManifestDigest = root.manifestDigest + record.namespaceManifestEpoch = record.helperEpoch + record.localDeadline = try makeLocalDeadline( + expiresAt: try messageExpiry(candidate.message, label: 27) + ) + record.lastIncoming = root.message + record.lastOutgoing = candidate.message + record.state = .namespaceAuthorizationPrepared + try persist(record) + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsNamespaceProtocol.authorizationPath, + body: candidate.message, + responseBody: false + ) + notice = .namespaceAuthorizationPending(recordID: record.id) + case .namespaceAuthorizationPrepared: + if let deadline = record.localDeadline, !localDeadlineExpired(deadline) { + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsNamespaceProtocol.authorizationPath, + body: record.lastOutgoing, + responseBody: false + ) + } + guard let rootData = record.lastIncoming, + let enablement = record.namespaceEnablement else { + throw DiagnosticsProtocolError.invalidMessage + } + let root = try DiagnosticsNamespaceProtocol.validateRootManifest( + rootData, + enablement: enablement, + record: record + ) + let candidate = DiagnosticsNamespaceProtocol.AuthorizationCandidate( + message: record.lastOutgoing, + installationBinding: try installationBinding(from: record.lastOutgoing) + ) + let relative = try DiagnosticsNamespaceProtocol.authorizationRelativePath( + installationBinding: candidate.installationBinding + ) + let completed = try DiagnosticsNamespaceFileReader.read( + folderPath: currentFolderPath, + components: [DiagnosticsNamespaceProtocol.rootName] + relative.split(separator: "/").map(String.init) + ) + let digest = try DiagnosticsNamespaceProtocol.validateCompletedAuthorization( + completed, + candidate: candidate, + record: record, + root: root + ) + record.namespaceAuthorizationDigest = digest + record.namespaceInitialAppKeyID = record.appKeyID + record.namespaceAuthorizationEpoch = 1 + record.state = .namespaceActive + record.localDeadline = nil + record.lastIncoming = completed + try persist(record) + notice = .namespaceActive(recordID: record.id) + default: + throw DiagnosticsProtocolError.conflict + } + } + } + + func startAppKeyRotation(recordID: String) async { + await perform { + var record = try requiredActiveRecord(recordID) + guard record.pendingLifecycle == nil else { throw DiagnosticsProtocolError.conflict } + let currentKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let proposedKey = try proposedInstallationAppKey(for: record) + let request = try DiagnosticsPairingProtocol.makeAppKeyRotationRequest( + record: record, + proposedKey: proposedKey, + currentKey: currentKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + record.pendingLifecycle = DiagnosticsPairingRecord.PendingLifecycle( + kind: .appKey, + transitionDigest: try request.digest(), + latestMessage: request.canonical, + proposedAppSeed: proposedKey.rawRepresentation, + proposedHelperPublicKey: nil, + proposedHelperEpoch: nil, + proposedTLSSPKIPin: nil + ) + record.lastOutgoing = request.canonical + record.lastIncoming = nil + record.state = .lifecyclePending + record.localDeadline = try makeLocalDeadline( + expiresAt: try lifecycleExpiry(request) + ) + try persist(record) + invalidateCapability(record.id) + try await advanceLifecycle(&record) + } + } + + func startHelperKeyRotation(recordID: String, proposal: String, proof: String) async { + await perform { + var record = try requiredActiveRecord(recordID) + guard record.pendingLifecycle == nil else { throw DiagnosticsProtocolError.conflict } + let proposalMessage = try DiagnosticsPairingProtocol.decode( + DiagnosticsCrypto.base64URLDecode(proposal) + ) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + proposalMessage, + expectedType: .helperKeyRotationPropose, + record: record, + now: now() + ) + let proofMessage = try DiagnosticsPairingProtocol.decode( + DiagnosticsCrypto.base64URLDecode(proof) + ) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + proofMessage, + expectedType: .helperKeyRotationNewProof, + record: record, + prior: proposalMessage, + now: now() + ) + guard let proposedPublic = proofMessage.value.bytes(for: 13, count: 32), + let proposedEpoch = proofMessage.value.unsigned(for: 20) else { + throw DiagnosticsProtocolError.invalidMessage + } + let currentKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let confirmation = try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: proofMessage, + type: .helperKeyRotationConfirm, + transitionKind: nil, + transitionDigest: nil, + signer: currentKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + record.pendingLifecycle = DiagnosticsPairingRecord.PendingLifecycle( + kind: .helperKey, + transitionDigest: try proposalMessage.digest(), + latestMessage: confirmation.canonical, + proposedAppSeed: nil, + proposedHelperPublicKey: proposedPublic, + proposedHelperEpoch: proposedEpoch, + proposedTLSSPKIPin: nil + ) + record.lastOutgoing = confirmation.canonical + record.lastIncoming = nil + record.state = .lifecyclePending + record.localDeadline = try makeLocalDeadline( + expiresAt: try lifecycleExpiry(confirmation) + ) + try persist(record) + invalidateCapability(record.id) + try await advanceLifecycle(&record) + } + } + + func startTLSPinRotation(recordID: String, proposal: String) async { + await perform { + var record = try requiredActiveRecord(recordID) + guard record.pendingLifecycle == nil else { throw DiagnosticsProtocolError.conflict } + let proposalMessage = try DiagnosticsPairingProtocol.decode( + DiagnosticsCrypto.base64URLDecode(proposal) + ) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + proposalMessage, + expectedType: .tlsPinRotationPropose, + record: record, + now: now() + ) + guard let proposedPin = proposalMessage.value.bytes(for: 16, count: 32), + proposedPin != record.tlsSPKIPin else { + throw DiagnosticsProtocolError.invalidMessage + } + let currentKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let confirmation = try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: proposalMessage, + type: .tlsPinRotationConfirm, + transitionKind: nil, + transitionDigest: nil, + signer: currentKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + record.pendingLifecycle = DiagnosticsPairingRecord.PendingLifecycle( + kind: .tlsPin, + transitionDigest: try proposalMessage.digest(), + latestMessage: confirmation.canonical, + proposedAppSeed: nil, + proposedHelperPublicKey: nil, + proposedHelperEpoch: nil, + proposedTLSSPKIPin: proposedPin + ) + record.lastOutgoing = confirmation.canonical + record.lastIncoming = nil + record.state = .lifecyclePending + record.localDeadline = try makeLocalDeadline( + expiresAt: try lifecycleExpiry(confirmation) + ) + try persist(record) + invalidateCapability(record.id) + try await advanceLifecycle(&record) + } + } + + func continueLifecycle(recordID: String) async { + await perform { + var record = try requiredRecord(recordID) + guard record.state == .lifecyclePending, record.pendingLifecycle != nil else { + throw DiagnosticsProtocolError.conflict + } + try requireLocalDeadline(record) + try await advanceLifecycle(&record) + } + } + + func abortLifecycle(recordID: String) async { + await perform { + var record = try requiredRecord(recordID) + guard let pending = record.pendingLifecycle, + record.state == .lifecyclePending else { + throw DiagnosticsProtocolError.conflict + } + try requireLocalDeadline(record) + var latest = try DiagnosticsPairingProtocol.decode(pending.latestMessage) + let transport = try makeTransport(record) + switch latest.type { + case .appKeyRotationRequest, .helperKeyRotationConfirm, .tlsPinRotationConfirm: + _ = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: latest.canonical, + responseBody: false + ) + case .appKeyRotationNewProof: + guard let responseData = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: latest.canonical, + responseBody: true + ) else { throw DiagnosticsProtocolError.invalidMessage } + let response = try DiagnosticsPairingProtocol.decode(responseData) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + response, + expectedType: .appKeyRotationAccept, + record: record, + prior: latest, + now: now() + ) + try saveLifecycleLatest(response, outgoing: false, record: &record) + latest = response + case .appKeyRotationAccept: + break + case .lifecycleAbort: + try await sendPreparedLifecycleAbort(&record) + return + default: + // Type 21 may already have committed at the helper and type 22 + // proves that it did. Neither can be silently rolled back. + throw DiagnosticsProtocolError.conflict + } + + guard let currentPending = record.pendingLifecycle else { + throw DiagnosticsProtocolError.invalidMessage + } + let currentKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: record.appSeed + ) + let abort = try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: latest, + type: .lifecycleAbort, + transitionKind: currentPending.kind, + transitionDigest: currentPending.transitionDigest, + signer: currentKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + try saveLifecycleLatest(abort, outgoing: true, record: &record) + try await sendPreparedLifecycleAbort(&record) + } + } + + func canDiscardExpiredLifecycle(_ record: DiagnosticsPairingRecord) -> Bool { + guard record.state == .lifecyclePending, + let pending = record.pendingLifecycle, + let latest = try? DiagnosticsPairingProtocol.decode(pending.latestMessage), + [.appKeyRotationRequest, .appKeyRotationNewProof, + .appKeyRotationAccept, .helperKeyRotationConfirm, + .tlsPinRotationConfirm, .lifecycleAbort].contains(latest.type), + let expires = latest.value.unsigned(for: 22), + let discardAfter = try? DiagnosticsPairingProtocol.checkedAdding( + expires, + DiagnosticsPairingProtocol.maximumClockSkew + ) else { + return false + } + if let deadline = record.localDeadline, localDeadlineExpired(deadline) { + return true + } + let seconds = now().timeIntervalSince1970.rounded(.down) + guard seconds >= 0, seconds < Double(UInt64.max) else { return false } + return UInt64(seconds) > discardAfter + } + + func discardExpiredLifecycle(recordID: String) { + do { + var record = try requiredRecord(recordID) + guard canDiscardExpiredLifecycle(record) else { + throw DiagnosticsProtocolError.conflict + } + record.pendingLifecycle = nil + record.state = record.namespaceAuthorizationEpoch > 0 ? .namespaceActive : .active + record.localDeadline = nil + invalidateCapability(record.id) + try persist(record) + lastError = nil + } catch let error as DiagnosticsProtocolError { + lastError = error + } catch { + lastError = .unavailable + } + } + + func continueNamespaceAuthorizationRefresh(recordID: String, currentFolderPath: String) async { + await perform { + var record = try requiredRecord(recordID) + switch record.state { + case .namespaceAuthorizationRefreshRequired: + guard let namespaceID = record.namespaceID, + let rootDigest = record.namespaceRootDigest, + let storedManifestDigest = record.namespaceManifestDigest, + let storedManifestEpoch = record.namespaceManifestEpoch, + let priorAuthorizationDigest = record.namespaceAuthorizationDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + let rootData = try DiagnosticsNamespaceFileReader.read( + folderPath: currentFolderPath, + components: [DiagnosticsNamespaceProtocol.rootName, DiagnosticsNamespaceProtocol.rootManifestName] + ) + guard DiagnosticsNamespaceProtocol.recordDigest(rootData) == rootDigest, + let rootValue = try? DiagnosticsDeterministicCBOR.decode(rootData), + rootValue.bytes(for: 7, count: 32) == namespaceID, + let rootHelperEpoch = rootValue.unsigned(for: 15), + storedManifestEpoch >= rootHelperEpoch, + storedManifestEpoch <= record.helperEpoch else { + throw DiagnosticsProtocolError.conflict + } + if storedManifestEpoch == rootHelperEpoch, + storedManifestDigest != rootDigest { + throw DiagnosticsProtocolError.conflict + } + var manifestDigest = storedManifestDigest + var manifestEpoch = storedManifestEpoch + if record.helperEpoch > storedManifestEpoch { + guard (try DiagnosticsPairingProtocol.checkedAdding(storedManifestEpoch, 1)) == + record.helperEpoch else { + throw DiagnosticsProtocolError.conflict + } + let currentManifest = try DiagnosticsNamespaceFileReader.read( + folderPath: currentFolderPath, + components: [ + DiagnosticsNamespaceProtocol.rootName, + "manifest-epochs", + "\(record.helperEpoch).helper-manifest.cbor", + ] + ) + let priorManifest: Data + if storedManifestEpoch == rootHelperEpoch { + priorManifest = rootData + } else { + priorManifest = try DiagnosticsNamespaceFileReader.read( + folderPath: currentFolderPath, + components: [ + DiagnosticsNamespaceProtocol.rootName, + "manifest-epochs", + "\(storedManifestEpoch).helper-manifest.cbor", + ] + ) + } + guard DiagnosticsNamespaceProtocol.recordDigest(priorManifest) == storedManifestDigest else { + throw DiagnosticsProtocolError.conflict + } + manifestDigest = try DiagnosticsNamespaceProtocol.validateHelperEpochManifest( + currentManifest, + rootData: rootData, + priorManifestData: priorManifest, + record: record + ) + manifestEpoch = record.helperEpoch + } + let root = DiagnosticsNamespaceProtocol.RootManifest( + message: rootData, + namespaceID: namespaceID, + rootDigest: rootDigest, + manifestDigest: manifestDigest + ) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let candidate = try DiagnosticsNamespaceProtocol.makeAuthorizationEpoch( + record: record, + root: root, + priorAuthorizationDigest: priorAuthorizationDigest, + appKey: key, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + record.namespaceManifestDigest = manifestDigest + record.namespaceManifestEpoch = manifestEpoch + record.lastOutgoing = candidate.message + record.lastIncoming = rootData + record.state = .namespaceAuthorizationRefreshPrepared + record.localDeadline = try makeLocalDeadline( + expiresAt: try messageExpiry(candidate.message, label: 27) + ) + try persist(record) + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsNamespaceProtocol.authorizationPath, + body: candidate.message, + responseBody: false + ) + notice = .namespaceAuthorizationPending(recordID: record.id) + case .namespaceAuthorizationRefreshPrepared: + if let deadline = record.localDeadline, !localDeadlineExpired(deadline) { + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsNamespaceProtocol.authorizationPath, + body: record.lastOutgoing, + responseBody: false + ) + } + guard let rootData = record.lastIncoming, + let namespaceID = record.namespaceID, + let rootDigest = record.namespaceRootDigest, + let manifestDigest = record.namespaceManifestDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + let candidate = DiagnosticsNamespaceProtocol.AuthorizationCandidate( + message: record.lastOutgoing, + installationBinding: try installationBinding(from: record.lastOutgoing) + ) + let epoch = try DiagnosticsPairingProtocol.checkedAdding( + record.namespaceAuthorizationEpoch, + 1 + ) + let relative = try DiagnosticsNamespaceProtocol.authorizationEpochRelativePath( + installationBinding: candidate.installationBinding, + epoch: epoch + ) + let completed = try DiagnosticsNamespaceFileReader.read( + folderPath: currentFolderPath, + components: [DiagnosticsNamespaceProtocol.rootName] + relative.split(separator: "/").map(String.init) + ) + let root = DiagnosticsNamespaceProtocol.RootManifest( + message: rootData, + namespaceID: namespaceID, + rootDigest: rootDigest, + manifestDigest: manifestDigest + ) + let digest = try DiagnosticsNamespaceProtocol.validateCompletedAuthorizationEpoch( + completed, + candidate: candidate, + record: record, + root: root + ) + record.namespaceAuthorizationDigest = digest + record.namespaceAuthorizationEpoch = epoch + record.lastIncoming = completed + record.state = .namespaceActive + record.localDeadline = nil + try persist(record) + notice = .namespaceActive(recordID: record.id) + default: + throw DiagnosticsProtocolError.conflict + } + } + } + + func revoke(recordID: String, reason: DiagnosticsPairingProtocol.RevocationReason) async { + await perform { + var record = try requiredActiveRecord(recordID) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let request = try DiagnosticsPairingProtocol.makeRevocationRequest( + record: record, + reason: reason, + currentKey: key, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + record.lastOutgoing = request.canonical + record.lastIncoming = nil + record.state = .revocationPrepared + record.localDeadline = try makeLocalDeadline( + expiresAt: try lifecycleExpiry(request) + ) + try persist(record) + try await sendPreparedRevocation(&record) + } + } + + func retryRevocation(recordID: String) async { + await perform { + var record = try requiredRecord(recordID) + guard record.state == .revocationPrepared else { throw DiagnosticsProtocolError.conflict } + try requireLocalDeadline(record) + try await sendPreparedRevocation(&record) + } + } + + func resetOrphanedCredentialsForRepair() { + do { + try credentialStore.resetForExplicitRepair() + records = [] + capabilityStates = [:] + capabilityValidUntil = [:] + hasInstallationMarker = false + hasInstallationCredential = false + notice = .none + lastError = nil + } catch let error as DiagnosticsProtocolError { + lastError = error + } catch { + lastError = .unavailable + } + } + + func clearNotice() { + notice = .none + } + + private func prepareBootstrap( + _ record: inout DiagnosticsPairingRecord, + type: DiagnosticsPairingProtocol.MessageType, + preparedState: DiagnosticsPairingRecord.State + ) throws { + try requireLocalDeadline(record) + guard let incoming = record.lastIncoming else { throw DiagnosticsProtocolError.invalidMessage } + let prior = try DiagnosticsPairingProtocol.decode(incoming) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let message = try DiagnosticsPairingProtocol.makeBootstrapTransition( + prior: prior, + type: type, + appPrivateKey: key, + now: now(), + hardExpiry: record.hardExpiry + ) + record.lastOutgoing = message.canonical + record.state = preparedState + try persist(record) + } + + private func advanceLifecycle(_ record: inout DiagnosticsPairingRecord) async throws { + while let pending = record.pendingLifecycle { + try requireLocalDeadline(record) + let latest = try DiagnosticsPairingProtocol.decode(pending.latestMessage) + switch latest.type { + case .appKeyRotationRequest: + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: latest.canonical, + responseBody: false + ) + guard let proposedSeed = pending.proposedAppSeed else { + throw DiagnosticsProtocolError.invalidMessage + } + let proposedKey = try Curve25519.Signing.PrivateKey(rawRepresentation: proposedSeed) + let proof = try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: latest, + type: .appKeyRotationNewProof, + transitionKind: nil, + transitionDigest: nil, + signer: proposedKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + try saveLifecycleLatest(proof, outgoing: true, record: &record) + case .appKeyRotationNewProof: + let transport = try makeTransport(record) + guard let responseData = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: latest.canonical, + responseBody: true + ) else { throw DiagnosticsProtocolError.invalidMessage } + let response = try DiagnosticsPairingProtocol.decode(responseData) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + response, + expectedType: .appKeyRotationAccept, + record: record, + prior: latest, + now: now() + ) + try saveLifecycleLatest(response, outgoing: false, record: &record) + case .appKeyRotationAccept: + guard let proposedSeed = pending.proposedAppSeed else { + throw DiagnosticsProtocolError.invalidMessage + } + let proposedKey = try Curve25519.Signing.PrivateKey(rawRepresentation: proposedSeed) + let finalize = try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: latest, + type: .lifecycleFinalize, + transitionKind: .appKey, + transitionDigest: pending.transitionDigest, + signer: proposedKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + try saveLifecycleLatest(finalize, outgoing: true, record: &record) + case .helperKeyRotationConfirm, .tlsPinRotationConfirm: + let transport = try makeTransport(record) + _ = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: latest.canonical, + responseBody: false + ) + let currentKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let finalize = try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: latest, + type: .lifecycleFinalize, + transitionKind: pending.kind, + transitionDigest: pending.transitionDigest, + signer: currentKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + try saveLifecycleLatest(finalize, outgoing: true, record: &record) + case .lifecycleFinalize: + let transport = try makeTransport(record) + guard let responseData = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: latest.canonical, + responseBody: true + ) else { throw DiagnosticsProtocolError.invalidMessage } + let response = try DiagnosticsPairingProtocol.decode(responseData) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + response, + expectedType: .lifecycleActiveAck, + record: record, + prior: latest, + now: now() + ) + try saveLifecycleLatest(response, outgoing: false, record: &record) + case .lifecycleActiveAck: + try await confirmProposedLifecycleState(&record, acknowledgment: latest) + return + default: + throw DiagnosticsProtocolError.invalidMessage + } + } + throw DiagnosticsProtocolError.invalidMessage + } + + private func saveLifecycleLatest( + _ message: DiagnosticsPairingProtocol.Message, + outgoing: Bool, + record: inout DiagnosticsPairingRecord + ) throws { + guard var pending = record.pendingLifecycle else { + throw DiagnosticsProtocolError.invalidMessage + } + pending.latestMessage = message.canonical + record.pendingLifecycle = pending + if outgoing { + record.lastOutgoing = message.canonical + } else { + record.lastIncoming = message.canonical + } + try persist(record) + } + + private func confirmProposedLifecycleState( + _ record: inout DiagnosticsPairingRecord, + acknowledgment: DiagnosticsPairingProtocol.Message + ) async throws { + guard let pending = record.pendingLifecycle else { + throw DiagnosticsProtocolError.invalidMessage + } + var proposed = record + let proposedKey: Curve25519.Signing.PrivateKey + switch pending.kind { + case .appKey: + guard let seed = pending.proposedAppSeed else { throw DiagnosticsProtocolError.invalidMessage } + proposedKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed) + proposed.appSeed = seed + proposed.appPublicKey = proposedKey.publicKey.rawRepresentation + proposed.appKeyID = DiagnosticsCrypto.keyID(publicKey: proposed.appPublicKey) + proposed.appEpoch = try DiagnosticsPairingProtocol.checkedAdding(proposed.appEpoch, 1) + case .helperKey: + guard let publicKey = pending.proposedHelperPublicKey, + let epoch = pending.proposedHelperEpoch else { + throw DiagnosticsProtocolError.invalidMessage + } + proposedKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + proposed.helperPublicKey = publicKey + proposed.helperKeyID = DiagnosticsCrypto.keyID(publicKey: publicKey) + proposed.helperEpoch = epoch + case .tlsPin: + guard let pin = pending.proposedTLSSPKIPin else { + throw DiagnosticsProtocolError.invalidMessage + } + proposedKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + proposed.tlsSPKIPin = pin + } + proposed.currentCredentialStateDigest = try acknowledgment.digest() + let query = try DiagnosticsCapabilityProtocol.makeQuery( + record: proposed, + appKey: proposedKey, + nonce: try DiagnosticsCrypto.randomBytes(count: 32), + now: now() + ) + let transport = try makeTransport(proposed) + guard let response = try await transport.post( + path: DiagnosticsCapabilityProtocol.path, + body: query.message, + responseBody: true + ) else { throw DiagnosticsProtocolError.invalidMessage } + let expires = try DiagnosticsCapabilityProtocol.validateResponse( + response, + query: query, + record: proposed, + now: now() + ) + proposed.pendingLifecycle = nil + proposed.state = proposed.namespaceAuthorizationEpoch > 0 + ? .namespaceAuthorizationRefreshRequired + : .active + proposed.localDeadline = nil + record = proposed + capabilityStates[record.id] = .available + capabilityValidUntil[record.id] = try makeContinuousDeadline( + expiresAt: expires, + maximumLifetime: 120 + ) + try persist(record) + } + + private func sendPreparedRevocation(_ record: inout DiagnosticsPairingRecord) async throws { + try requireLocalDeadline(record) + let request = try DiagnosticsPairingProtocol.decode(record.lastOutgoing) + guard request.type == .revocationRequest else { throw DiagnosticsProtocolError.invalidMessage } + let transport = try makeTransport(record) + guard let responseData = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: request.canonical, + responseBody: true + ) else { throw DiagnosticsProtocolError.invalidMessage } + let response = try DiagnosticsPairingProtocol.decode(responseData) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + response, + expectedType: .revocationRecord, + record: record, + prior: request, + now: now() + ) + let revokedEpoch = try DiagnosticsPairingProtocol.checkedAdding(record.appEpoch, 1) + guard response.value.unsigned(for: 18) == revokedEpoch else { + throw DiagnosticsProtocolError.invalidMessage + } + record.appEpoch = revokedEpoch + record.currentCredentialStateDigest = try response.digest() + record.lastIncoming = response.canonical + record.state = .revoked + record.pendingLifecycle = nil + record.localDeadline = nil + capabilityStates[record.id] = .unavailable + capabilityValidUntil.removeValue(forKey: record.id) + try persist(record) + } + + private func sendPreparedLifecycleAbort( + _ record: inout DiagnosticsPairingRecord + ) async throws { + try requireLocalDeadline(record) + guard let pending = record.pendingLifecycle else { + throw DiagnosticsProtocolError.invalidMessage + } + let request = try DiagnosticsPairingProtocol.decode(pending.latestMessage) + guard request.type == .lifecycleAbort else { + throw DiagnosticsProtocolError.invalidMessage + } + let transport = try makeTransport(record) + guard let responseData = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: request.canonical, + responseBody: true + ) else { throw DiagnosticsProtocolError.invalidMessage } + let response = try DiagnosticsPairingProtocol.decode(responseData) + try DiagnosticsPairingProtocol.validateLifecycleMessage( + response, + expectedType: .lifecycleAbortAck, + record: record, + prior: request, + now: now() + ) + guard response.value.unsigned(for: 29) == pending.kind.rawValue, + response.value.bytes(for: 28, count: 32) == pending.transitionDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + record.lastIncoming = response.canonical + record.pendingLifecycle = nil + record.state = record.namespaceAuthorizationEpoch > 0 ? .namespaceActive : .active + record.localDeadline = nil + invalidateCapability(record.id) + try persist(record) + } + + private func sendPreparedAbort(_ record: inout DiagnosticsPairingRecord) async throws { + try requireLocalDeadline(record) + let request = try DiagnosticsPairingProtocol.decode(record.lastOutgoing) + guard request.type == .abort else { throw DiagnosticsProtocolError.invalidMessage } + let transport = try makeTransport(record) + guard let responseData = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: request.canonical, + responseBody: true + ) else { throw DiagnosticsProtocolError.invalidMessage } + let response = try DiagnosticsPairingProtocol.decode(responseData) + try DiagnosticsPairingProtocol.validateBootstrapResponse( + response, + expectedType: .abortAck, + prior: request, + now: now() + ) + guard response.value.bytes(for: 9, count: 32) == record.helperPublicKey, + response.value.bytes(for: 10, count: 32) == record.helperKeyID, + response.value.bytes(for: 11, count: 32) == record.homeserverBinding, + response.value.bytes(for: 12, count: 32) == record.folderBinding, + response.value.bytes(for: 18, count: 32) == record.appPublicKey, + response.value.bytes(for: 19, count: 32) == record.appKeyID else { + throw DiagnosticsProtocolError.invalidMessage + } + try credentialStore.delete(record) + records.removeAll { $0.id == record.id } + capabilityStates.removeValue(forKey: record.id) + capabilityValidUntil.removeValue(forKey: record.id) + notice = .none + } + + private func sendPreparedBootstrap(_ record: inout DiagnosticsPairingRecord) async throws { + try requireLocalDeadline(record) + let outgoing = try DiagnosticsPairingProtocol.decode(record.lastOutgoing) + let expected: DiagnosticsPairingProtocol.MessageType + let receivedState: DiagnosticsPairingRecord.State + switch record.state { + case .requestPrepared: + expected = .helperAccept + receivedState = .acceptanceReceived + case .finalizePrepared: + expected = .finalizeAck + receivedState = .finalizeAcknowledged + case .receiptPrepared: + expected = .readyAck + receivedState = .readyAcknowledged + case .activatePrepared: + expected = .activeAck + receivedState = .active + default: + throw DiagnosticsProtocolError.conflict + } + let transport = try makeTransport(record) + guard let responseData = try await transport.post( + path: DiagnosticsPairingProtocol.path, + body: record.lastOutgoing, + responseBody: true + ) else { + throw DiagnosticsProtocolError.invalidMessage + } + let response = try DiagnosticsPairingProtocol.decode(responseData) + try DiagnosticsPairingProtocol.validateBootstrapResponse( + response, + expectedType: expected, + prior: outgoing, + now: now() + ) + guard response.value.bytes(for: 9, count: 32) == record.helperPublicKey, + response.value.bytes(for: 10, count: 32) == record.helperKeyID, + response.value.bytes(for: 11, count: 32) == record.homeserverBinding, + response.value.bytes(for: 12, count: 32) == record.folderBinding, + response.value.bytes(for: 18, count: 32) == record.appPublicKey, + response.value.bytes(for: 19, count: 32) == record.appKeyID else { + throw DiagnosticsProtocolError.invalidMessage + } + record.lastIncoming = response.canonical + record.currentCredentialStateDigest = try response.digest() + record.state = receivedState + if receivedState == .active { + record.localDeadline = nil + } + if expected == .helperAccept { + let fingerprint = try DiagnosticsCrypto.fingerprint( + appRequestDigest: try outgoing.digest(), + helperAcceptDigest: try response.digest() + ) + record.transcriptFingerprint = fingerprint + notice = .fingerprint(recordID: record.id, value: fingerprint) + } + try persist(record) + } + + private func installationBinding(from authorization: Data) throws -> Data { + let value = try DiagnosticsDeterministicCBOR.decode(authorization) + guard let binding = value.bytes(for: 8, count: 32) else { + throw DiagnosticsProtocolError.invalidMessage + } + return binding + } + + private func requiredRecord(_ id: String) throws -> DiagnosticsPairingRecord { + guard let record = records.first(where: { $0.id == id }) else { + throw DiagnosticsProtocolError.unavailable + } + return record + } + + private func requiredActiveRecord(_ id: String) throws -> DiagnosticsPairingRecord { + let record = try requiredRecord(id) + guard [.active, .namespaceActive].contains(record.state) else { + throw DiagnosticsProtocolError.unavailable + } + return record + } + + private func proposedInstallationAppKey( + for record: DiagnosticsPairingRecord + ) throws -> Curve25519.Signing.PrivateKey { + let installation = try credentialStore.installationCredential() + let installationKey = installation.privateKey + let installationPublic = installationKey.publicKey.rawRepresentation + if installationPublic != record.appPublicKey { + // A prior explicit rotation already selected this installation-wide + // key. This exact authorization catches up independently. + return installationKey + } + + let stableStates: Set = [.active, .namespaceActive] + guard records.filter({ $0.state != .revoked }).allSatisfy({ candidate in + candidate.appPublicKey == installationPublic && + stableStates.contains(candidate.state) && + candidate.pendingLifecycle == nil + }) else { + // Do not create another key generation while any authorization is + // pending or still bound to the previous installation key. + throw DiagnosticsProtocolError.conflict + } + let proposed = Curve25519.Signing.PrivateKey() + try credentialStore.advanceInstallationAppKey( + expected: installationKey, + proposed: proposed + ) + return proposed + } + + private func makeTransport(_ record: DiagnosticsPairingRecord) throws -> any DiagnosticsTransporting { + try transportFactory(record.endpointHost, record.endpointPort, record.tlsSPKIPin) + } + + private func invalidateCapability(_ recordID: String) { + capabilityStates[recordID] = .unavailable + capabilityValidUntil.removeValue(forKey: recordID) + } + + private func makeLocalDeadline( + expiresAt: UInt64 + ) throws -> DiagnosticsPairingRecord.LocalDeadline { + let wall = try wallSeconds() + guard expiresAt > wall else { throw DiagnosticsProtocolError.expired } + let lifetime = min( + expiresAt - wall, + DiagnosticsPairingProtocol.maximumLifetime + ) + let continuous = continuousNow() + guard continuous.isFinite, continuous >= 0 else { + throw DiagnosticsProtocolError.unavailable + } + let continuousExpiry = continuous + TimeInterval(lifetime) + guard continuousExpiry.isFinite else { + throw DiagnosticsProtocolError.unavailable + } + return DiagnosticsPairingRecord.LocalDeadline( + createdWallSeconds: wall, + createdContinuousSeconds: continuous, + expiresContinuousSeconds: continuousExpiry + ) + } + + private func makeContinuousDeadline( + expiresAt: UInt64, + maximumLifetime: UInt64 + ) throws -> TimeInterval { + let wall = try wallSeconds() + guard expiresAt > wall else { throw DiagnosticsProtocolError.expired } + let lifetime = min(expiresAt - wall, maximumLifetime) + let continuous = continuousNow() + let deadline = continuous + TimeInterval(lifetime) + guard continuous.isFinite, continuous >= 0, deadline.isFinite else { + throw DiagnosticsProtocolError.unavailable + } + return deadline + } + + private func lifecycleExpiry( + _ message: DiagnosticsPairingProtocol.Message + ) throws -> UInt64 { + guard let expires = message.value.unsigned(for: 22) else { + throw DiagnosticsProtocolError.invalidMessage + } + return expires + } + + private func messageExpiry(_ data: Data, label: UInt64) throws -> UInt64 { + let value = try DiagnosticsDeterministicCBOR.decode(data) + guard let expires = value.unsigned(for: label) else { + throw DiagnosticsProtocolError.invalidMessage + } + return expires + } + + private func requireLocalDeadline(_ record: DiagnosticsPairingRecord) throws { + guard let deadline = record.localDeadline, + !localDeadlineExpired(deadline) else { + throw DiagnosticsProtocolError.expired + } + } + + private func localDeadlineExpired(_ deadline: DiagnosticsPairingRecord.LocalDeadline) -> Bool { + guard let wall = try? wallSeconds() else { return true } + let continuous = continuousNow() + guard continuous.isFinite, + continuous >= deadline.createdContinuousSeconds, + continuous < deadline.expiresContinuousSeconds else { + return true + } + let wallElapsed = Double(wall) - Double(deadline.createdWallSeconds) + let continuousElapsed = continuous - deadline.createdContinuousSeconds + return abs(wallElapsed - continuousElapsed) > + Double(DiagnosticsPairingProtocol.maximumClockSkew) + } + + private func wallSeconds() throws -> UInt64 { + let seconds = now().timeIntervalSince1970.rounded(.down) + guard seconds >= 0, seconds < Double(UInt64.max) else { + throw DiagnosticsProtocolError.invalidMessage + } + return UInt64(seconds) + } + + private func persist(_ record: DiagnosticsPairingRecord) throws { + try credentialStore.save(record) + if let index = records.firstIndex(where: { $0.id == record.id }) { + records[index] = record + } else { + records.append(record) + records.sort { $0.id < $1.id } + } + hasInstallationMarker = true + hasInstallationCredential = true + } + + private func perform(_ operation: () async throws -> Void) async { + guard !isBusy else { return } + isBusy = true + lastError = nil + defer { isBusy = false } + do { + try await operation() + } catch let error as DiagnosticsProtocolError { + lastError = error + if error == .recoveryRequired { notice = .recoveryRequired } + } catch { + lastError = .unavailable + } + } +} + +enum DiagnosticsContinuousClock { + private static let timebase: mach_timebase_info_data_t = { + var info = mach_timebase_info_data_t() + mach_timebase_info(&info) + return info + }() + + static func seconds() -> TimeInterval { + let ticks = mach_continuous_time() + return Double(ticks) * Double(timebase.numer) / Double(timebase.denom) / 1_000_000_000 + } +} + +enum DiagnosticsNamespaceFileReader { + static func read(folderPath: String, components: [String]) throws -> Data { + guard !folderPath.isEmpty, + !components.isEmpty, + components.count <= 5, + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") }) else { + throw DiagnosticsProtocolError.unsupported + } + let rootDescriptor = open(folderPath, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard rootDescriptor >= 0 else { throw DiagnosticsProtocolError.unsupported } + var descriptors = [rootDescriptor] + defer { descriptors.reversed().forEach { close($0) } } + + var current = rootDescriptor + for component in components.dropLast() { + let next = component.withCString { + openat(current, $0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + } + guard next >= 0 else { throw DiagnosticsProtocolError.unavailable } + descriptors.append(next) + current = next + } + let file = components.last!.withCString { + openat(current, $0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + } + guard file >= 0 else { throw DiagnosticsProtocolError.unavailable } + descriptors.append(file) + + var status = stat() + guard fstat(file, &status) == 0, + (status.st_mode & S_IFMT) == S_IFREG, + status.st_nlink == 1, + status.st_size > 0, + status.st_size <= DiagnosticsDeterministicCBOR.maximumMessageBytes else { + throw DiagnosticsProtocolError.conflict + } + var data = Data(count: Int(status.st_size)) + let bytesRead = data.withUnsafeMutableBytes { buffer in + Darwin.read(file, buffer.baseAddress, buffer.count) + } + guard bytesRead == data.count else { throw DiagnosticsProtocolError.conflict } + var extra: UInt8 = 0 + guard Darwin.read(file, &extra, 1) == 0 else { throw DiagnosticsProtocolError.conflict } + return data + } +} diff --git a/ios/VaultSync/Services/DiagnosticsPairingProtocol.swift b/ios/VaultSync/Services/DiagnosticsPairingProtocol.swift new file mode 100644 index 0000000..9cede44 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsPairingProtocol.swift @@ -0,0 +1,640 @@ +import CryptoKit +import Foundation + +enum DiagnosticsPairingProtocol { + static let capability = "eu.vaultsync.diagnostics.helper-pairing/1" + static let path = "/api/v1/diagnostics/pairing" + static let maximumClockSkew: UInt64 = 120 + static let maximumLifetime: UInt64 = 300 + + enum MessageType: UInt64, Codable, Sendable { + case qr = 0 + case appRequest = 1 + case helperAccept = 2 + case finalize = 3 + case finalizeAck = 4 + case receipt = 5 + case readyAck = 6 + case activate = 7 + case activeAck = 8 + case abort = 9 + case abortAck = 10 + case appKeyRotationRequest = 11 + case appKeyRotationNewProof = 12 + case appKeyRotationAccept = 13 + case helperKeyRotationPropose = 14 + case helperKeyRotationNewProof = 15 + case helperKeyRotationConfirm = 16 + case tlsPinRotationPropose = 17 + case tlsPinRotationConfirm = 18 + case revocationRequest = 19 + case revocationRecord = 20 + case lifecycleFinalize = 21 + case lifecycleActiveAck = 22 + case lifecycleAbort = 23 + case lifecycleAbortAck = 24 + } + + enum TransitionKind: UInt64, Codable, Sendable { + case appKey = 1 + case helperKey = 2 + case tlsPin = 3 + } + + enum RevocationReason: UInt64, CaseIterable, Codable, Sendable { + case userRequest = 1 + case lostApp = 2 + case folderRemoved = 3 + case suspectedCompromise = 4 + } + + struct Message: Equatable, Sendable { + let type: MessageType + let value: DiagnosticsCBORValue + let canonical: Data + + var domain: String? { DiagnosticsPairingProtocol.domain(for: type) } + + func digest() throws -> Data { + guard let domain else { throw DiagnosticsProtocolError.invalidMessage } + return try DiagnosticsCrypto.signedMessageDigest(domain: domain, value: value) + } + } + + static func decodeQR(_ encoded: String, now: Date = Date()) throws -> Message { + let message = try decode(DiagnosticsCrypto.base64URLDecode(encoded)) + guard message.type == .qr else { throw DiagnosticsProtocolError.invalidMessage } + try validateClock(message, now: now, allowExpiredSkew: false) + return message + } + + static func decode(_ data: Data) throws -> Message { + let value = try DiagnosticsDeterministicCBOR.decode(data) + guard let rawType = value.unsigned(for: 4), let type = MessageType(rawValue: rawType) else { + throw DiagnosticsProtocolError.invalidMessage + } + let message = Message(type: type, value: value, canonical: data) + try validateSchema(message) + if type != .qr { + try verifySignature(message) + } + return message + } + + static func makeAppRequest( + invitation: Message, + appPrivateKey: Curve25519.Signing.PrivateKey, + selectedDeviceID: String, + selectedFolderID: String, + appNonce: Data + ) throws -> Message { + let selectedDeviceDigest = try DiagnosticsSyncthingBinding.deviceDigest(selectedDeviceID) + let selectedFolderDigest = try DiagnosticsSyncthingBinding.folderDigest(selectedFolderID) + guard invitation.type == .qr, + appNonce.count == 32, appNonce.contains(where: { $0 != 0 }), + invitation.value.bytes(for: 13, count: 32) == selectedDeviceDigest, + invitation.value.bytes(for: 14, count: 32) == selectedFolderDigest, + let secret = invitation.value.bytes(for: 17, count: 32), + let fields = invitation.value.fields else { + throw DiagnosticsProtocolError.invalidMessage + } + var requestFields = fields.compactMap { field -> DiagnosticsCBORField? in + guard field.label <= 16 else { return nil } + if field.label == 4 { + return DiagnosticsCBORField(label: 4, value: .unsigned(MessageType.appRequest.rawValue)) + } + return field + } + let appPublic = appPrivateKey.publicKey.rawRepresentation + requestFields.append(contentsOf: [ + DiagnosticsCBORField(label: 18, value: .bytes(appPublic)), + DiagnosticsCBORField(label: 19, value: .bytes(DiagnosticsCrypto.keyID(publicKey: appPublic))), + DiagnosticsCBORField(label: 20, value: .bytes(appNonce)), + DiagnosticsCBORField(label: 23, value: .unsigned(1)), + DiagnosticsCBORField(label: 24, value: .unsigned(invitation.value.unsigned(for: 24)!)), + ]) + var unsigned = DiagnosticsCBORValue.map(requestFields) + let hmacBody = try DiagnosticsDeterministicCBOR.encode(unsigned) + let hmac = DiagnosticsCrypto.hmacSHA256( + key: secret, + domain: "eu.vaultsync.helper-pairing/v1/bootstrap-hmac\0", + body: hmacBody + ) + requestFields.append(DiagnosticsCBORField(label: 21, value: .bytes(hmac))) + unsigned = .map(requestFields) + return try sign(unsigned, as: .appRequest, with: appPrivateKey) + } + + static func makeBootstrapTransition( + prior: Message, + type: MessageType, + appPrivateKey: Curve25519.Signing.PrivateKey, + now: Date, + hardExpiry: UInt64 + ) throws -> Message { + guard prior.type.rawValue >= MessageType.helperAccept.rawValue, + prior.type.rawValue <= MessageType.abort.rawValue, + [.finalize, .receipt, .activate, .abort].contains(type), + let priorFields = prior.value.fields else { + throw DiagnosticsProtocolError.invalidMessage + } + let issued = try unixSeconds(now) + let expires = min(try checkedAdding(issued, maximumLifetime), hardExpiry) + guard expires > issued else { throw DiagnosticsProtocolError.expired } + let copied: Set = [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 18, 19, 20, 22, 23, 24, 25] + var fields = priorFields.filter { copied.contains($0.label) } + fields.append(contentsOf: [ + DiagnosticsCBORField(label: 4, value: .unsigned(type.rawValue)), + DiagnosticsCBORField(label: 15, value: .unsigned(issued)), + DiagnosticsCBORField(label: 16, value: .unsigned(expires)), + DiagnosticsCBORField(label: 26, value: .bytes(try prior.digest())), + ]) + return try sign(.map(fields), as: type, with: appPrivateKey) + } + + static func makeLifecycleBase( + record: DiagnosticsPairingRecord, + type: MessageType, + nonce: Data, + now: Date + ) throws -> [DiagnosticsCBORField] { + let issued = try unixSeconds(now) + guard nonce.count == 32, nonce.contains(where: { $0 != 0 }) else { + throw DiagnosticsProtocolError.invalidMessage + } + return [ + DiagnosticsCBORField(label: 1, value: .text(capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(type.rawValue)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(record.appPublicKey)), + DiagnosticsCBORField(label: 8, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 11, value: .bytes(record.helperPublicKey)), + DiagnosticsCBORField(label: 12, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 15, value: .bytes(record.tlsSPKIPin)), + DiagnosticsCBORField(label: 17, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 19, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 21, value: .unsigned(issued)), + DiagnosticsCBORField(label: 22, value: .unsigned(try checkedAdding(issued, maximumLifetime))), + DiagnosticsCBORField(label: 23, value: .bytes(nonce)), + DiagnosticsCBORField(label: 26, value: .bytes(record.currentCredentialStateDigest)), + ] + } + + static func makeAppKeyRotationRequest( + record: DiagnosticsPairingRecord, + proposedKey: Curve25519.Signing.PrivateKey, + currentKey: Curve25519.Signing.PrivateKey, + nonce: Data, + now: Date + ) throws -> Message { + let proposedPublic = proposedKey.publicKey.rawRepresentation + var fields = try makeLifecycleBase(record: record, type: .appKeyRotationRequest, nonce: nonce, now: now) + fields.append(contentsOf: [ + DiagnosticsCBORField(label: 9, value: .bytes(proposedPublic)), + DiagnosticsCBORField(label: 10, value: .bytes(DiagnosticsCrypto.keyID(publicKey: proposedPublic))), + DiagnosticsCBORField(label: 18, value: .unsigned(try checkedAdding(record.appEpoch, 1))), + ]) + return try sign(.map(fields), as: .appKeyRotationRequest, with: currentKey) + } + + static func makeRevocationRequest( + record: DiagnosticsPairingRecord, + reason: RevocationReason, + currentKey: Curve25519.Signing.PrivateKey, + nonce: Data, + now: Date + ) throws -> Message { + var fields = try makeLifecycleBase(record: record, type: .revocationRequest, nonce: nonce, now: now) + fields.append(contentsOf: [ + DiagnosticsCBORField(label: 18, value: .unsigned(try checkedAdding(record.appEpoch, 1))), + DiagnosticsCBORField(label: 25, value: .unsigned(reason.rawValue)), + DiagnosticsCBORField(label: 27, value: .unsigned(1)), + ]) + return try sign(.map(fields), as: .revocationRequest, with: currentKey) + } + + static func makeLifecycleContinuation( + prior: Message, + type: MessageType, + transitionKind: TransitionKind?, + transitionDigest: Data?, + signer: Curve25519.Signing.PrivateKey, + nonce: Data, + now: Date + ) throws -> Message { + guard nonce.count == 32, nonce.contains(where: { $0 != 0 }), + let priorFields = prior.value.fields else { + throw DiagnosticsProtocolError.invalidMessage + } + let issued = try unixSeconds(now) + let localExpiry = try checkedAdding(issued, maximumLifetime) + guard let priorExpiry = prior.value.unsigned(for: 22) else { + throw DiagnosticsProtocolError.invalidMessage + } + let expires = min(localExpiry, priorExpiry) + guard expires > issued else { throw DiagnosticsProtocolError.expired } + + var template = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 4, value: .unsigned(type.rawValue)), + ]) + if type.rawValue >= MessageType.lifecycleFinalize.rawValue { + guard let transitionKind else { throw DiagnosticsProtocolError.invalidMessage } + template = .map([ + DiagnosticsCBORField(label: 4, value: .unsigned(type.rawValue)), + DiagnosticsCBORField(label: 29, value: .unsigned(transitionKind.rawValue)), + ]) + } else if type == .revocationRecord { + guard let origin = prior.value.unsigned(for: 27) else { throw DiagnosticsProtocolError.invalidMessage } + template = .map([ + DiagnosticsCBORField(label: 4, value: .unsigned(type.rawValue)), + DiagnosticsCBORField(label: 27, value: .unsigned(origin)), + ]) + } + let expected = try expectedLabels(type: type, template: template).filter { $0 != 255 } + var fields: [DiagnosticsCBORField] = [] + for label in expected { + let value: DiagnosticsCBORValue + switch label { + case 4: value = .unsigned(type.rawValue) + case 21: value = .unsigned(issued) + case 22: value = .unsigned(expires) + case 23: value = .bytes(nonce) + case 24: value = .bytes(try prior.digest()) + case 28: + guard let transitionDigest, transitionDigest.count == 32 else { + throw DiagnosticsProtocolError.invalidMessage + } + value = .bytes(transitionDigest) + case 29: + guard let transitionKind else { throw DiagnosticsProtocolError.invalidMessage } + value = .unsigned(transitionKind.rawValue) + default: + guard let copied = priorFields.first(where: { $0.label == label })?.value else { + throw DiagnosticsProtocolError.invalidMessage + } + value = copied + } + fields.append(DiagnosticsCBORField(label: label, value: value)) + } + return try sign(.map(fields), as: type, with: signer) + } + + static func validateClock(_ message: Message, now: Date, allowExpiredSkew: Bool = true) throws { + let issuedLabel: UInt64 = message.type.rawValue <= MessageType.abortAck.rawValue ? 15 : 21 + let expiresLabel: UInt64 = message.type.rawValue <= MessageType.abortAck.rawValue ? 16 : 22 + guard let issued = message.value.unsigned(for: issuedLabel), + let expires = message.value.unsigned(for: expiresLabel), + expires > issued, + expires - issued <= maximumLifetime else { + throw DiagnosticsProtocolError.invalidMessage + } + let current = try unixSeconds(now) + guard issued <= (try checkedAdding(current, maximumClockSkew)) else { + throw DiagnosticsProtocolError.expired + } + let allowance = allowExpiredSkew ? maximumClockSkew : 0 + guard current <= (try checkedAdding(expires, allowance)) else { + throw DiagnosticsProtocolError.expired + } + } + + static func validateBootstrapResponse( + _ response: Message, + expectedType: MessageType, + prior: Message, + now: Date + ) throws { + guard response.type == expectedType else { throw DiagnosticsProtocolError.invalidMessage } + try validateClock(response, now: now) + guard let responseExpiry = response.value.unsigned(for: 16), + let priorExpiry = prior.value.unsigned(for: 16), + responseExpiry <= priorExpiry else { + throw DiagnosticsProtocolError.invalidMessage + } + let exactLabels: [UInt64] = expectedType == .helperAccept + ? [5, 9, 10, 11, 12, 13, 14, 18, 19, 20, 23, 24] + : [5, 9, 10, 11, 12, 13, 14, 18, 19, 20, 22, 23, 24, 25] + for label in exactLabels where response.value.value(for: label) != prior.value.value(for: label) { + throw DiagnosticsProtocolError.invalidMessage + } + let priorBindingLabel: UInt64 = expectedType == .helperAccept ? 22 : 26 + let expectedDigest = try prior.digest() + guard response.value.bytes(for: priorBindingLabel, count: 32) == expectedDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + static func validateLifecycleMessage( + _ message: Message, + expectedType: MessageType, + record: DiagnosticsPairingRecord, + prior: Message? = nil, + now: Date + ) throws { + guard message.type == expectedType, + message.value.bytes(for: 5, count: 32) == record.homeserverBinding, + message.value.bytes(for: 6, count: 32) == record.folderBinding, + message.value.bytes(for: 7, count: 32) == record.appPublicKey, + message.value.bytes(for: 8, count: 32) == record.appKeyID, + message.value.bytes(for: 11, count: 32) == record.helperPublicKey, + message.value.bytes(for: 12, count: 32) == record.helperKeyID, + message.value.bytes(for: 15, count: 32) == record.tlsSPKIPin, + message.value.unsigned(for: 17) == record.appEpoch, + message.value.unsigned(for: 19) == record.helperEpoch, + message.value.bytes(for: 26, count: 32) == record.currentCredentialStateDigest else { + throw DiagnosticsProtocolError.invalidMessage + } + try validateClock(message, now: now) + if let prior { + guard message.value.bytes(for: 24, count: 32) == (try prior.digest()), + let messageExpiry = message.value.unsigned(for: 22), + let priorExpiry = prior.value.unsigned(for: 22), + messageExpiry <= priorExpiry else { + throw DiagnosticsProtocolError.invalidMessage + } + for label in [9, 10, 13, 14, 16, 18, 20, 25, 27, 28, 29] { + let left = message.value.value(for: UInt64(label)) + let right = prior.value.value(for: UInt64(label)) + if left != nil || right != nil { + guard left == right else { throw DiagnosticsProtocolError.invalidMessage } + } + } + } + } + + private static func sign( + _ value: DiagnosticsCBORValue, + as type: MessageType, + with key: Curve25519.Signing.PrivateKey + ) throws -> Message { + guard value.unsigned(for: 4) == type.rawValue, + value.value(for: 255) == nil, + let domain = domain(for: type) else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = try DiagnosticsDeterministicCBOR.encode(value) + var input = Data(domain.utf8) + input.append(body) + let signature = try key.signature(for: input) + guard case .map(var fields) = value else { throw DiagnosticsProtocolError.invalidMessage } + fields.append(DiagnosticsCBORField(label: 255, value: .bytes(signature))) + return try decode(try DiagnosticsDeterministicCBOR.encode(.map(fields))) + } + + private static func verifySignature(_ message: Message) throws { + guard let domain = domain(for: message.type), + let signerLabel = signerPublicKeyLabel(for: message), + let publicBytes = message.value.bytes(for: signerLabel, count: 32), + let signature = message.value.bytes(for: 255, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicBytes) + let body = try DiagnosticsDeterministicCBOR.encode(message.value.removing(labels: [255])) + var input = Data(domain.utf8) + input.append(body) + guard publicKey.isValidSignature(signature, for: input) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + private static func validateSchema(_ message: Message) throws { + let labels = try expectedLabels(type: message.type, template: message.value) + guard message.value.text(for: 1) == capability, + message.value.unsigned(for: 2) == 1, + message.value.unsigned(for: 3) == 1, + message.value.unsigned(for: 4) == message.type.rawValue, + let fields = message.value.fields, + fields.map(\.label) == labels else { + throw DiagnosticsProtocolError.invalidMessage + } + if message.type.rawValue <= MessageType.abortAck.rawValue { + try validateBootstrapFields(message) + } else { + try validateLifecycleFields(message) + } + } + + private static func validateBootstrapFields(_ message: Message) throws { + let uintLabels = Set([2, 3, 4, 15, 16, 24] + + (message.type.rawValue <= MessageType.appRequest.rawValue ? [7] : []) + + (message.type == .qr ? [] : [23])) + guard let fields = message.value.fields else { throw DiagnosticsProtocolError.invalidMessage } + for field in fields { + if field.label == 1 { guard case .text = field.value else { throw DiagnosticsProtocolError.invalidMessage }; continue } + if field.label == 6 { guard case .text = field.value else { throw DiagnosticsProtocolError.invalidMessage }; continue } + if uintLabels.contains(field.label) { guard case .unsigned = field.value else { throw DiagnosticsProtocolError.invalidMessage }; continue } + let expectedCount = field.label == 255 ? 64 : 32 + guard case .bytes(let bytes) = field.value, bytes.count == expectedCount, + expectedCount != 32 || bytes.contains(where: { $0 != 0 }) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + guard let issued = message.value.unsigned(for: 15), + let expires = message.value.unsigned(for: 16), + issued > 0, expires > issued, expires - issued <= maximumLifetime, + let helperPublic = message.value.bytes(for: 9, count: 32), + message.value.bytes(for: 10, count: 32) == DiagnosticsCrypto.keyID(publicKey: helperPublic), + (message.value.unsigned(for: 24) ?? 0) > 0 else { + throw DiagnosticsProtocolError.invalidMessage + } + if message.type.rawValue <= MessageType.appRequest.rawValue { + guard let host = message.value.text(for: 6), validEndpointHost(host), + let port = message.value.unsigned(for: 7), port > 0, port <= 65_535 else { + throw DiagnosticsProtocolError.invalidMessage + } + } + if message.type != .qr { + guard message.value.unsigned(for: 23) == 1, + let appPublic = message.value.bytes(for: 18, count: 32), + message.value.bytes(for: 19, count: 32) == DiagnosticsCrypto.keyID(publicKey: appPublic) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + var requiredNonzeroByteLabels: [UInt64] = [5, 9, 10, 11, 12, 13, 14] + if message.type.rawValue <= MessageType.appRequest.rawValue { + requiredNonzeroByteLabels.append(8) + } + for label in requiredNonzeroByteLabels { + guard message.value.bytes(for: label, count: 32)?.contains(where: { $0 != 0 }) == true else { + throw DiagnosticsProtocolError.invalidMessage + } + } + } + + private static func validateLifecycleFields(_ message: Message) throws { + guard let fields = message.value.fields else { throw DiagnosticsProtocolError.invalidMessage } + var uintLabels: Set = [2, 3, 4, 17, 19, 21, 22] + for label in [18, 20, 25, 27, 29] where message.value.value(for: UInt64(label)) != nil { + uintLabels.insert(UInt64(label)) + } + for field in fields { + if field.label == 1 { guard case .text = field.value else { throw DiagnosticsProtocolError.invalidMessage }; continue } + if uintLabels.contains(field.label) { guard case .unsigned = field.value else { throw DiagnosticsProtocolError.invalidMessage }; continue } + let count = field.label == 255 ? 64 : 32 + guard case .bytes(let bytes) = field.value, bytes.count == count, + count != 32 || bytes.contains(where: { $0 != 0 }) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + guard let issued = message.value.unsigned(for: 21), + let expires = message.value.unsigned(for: 22), + issued > 0, expires > issued, expires - issued <= maximumLifetime, + let appEpoch = message.value.unsigned(for: 17), appEpoch > 0, + let helperEpoch = message.value.unsigned(for: 19), helperEpoch > 0 else { + throw DiagnosticsProtocolError.invalidMessage + } + if let proposed = message.value.unsigned(for: 18) { + guard appEpoch < UInt64.max, proposed == appEpoch + 1 else { + throw DiagnosticsProtocolError.invalidMessage + } + } + if let proposed = message.value.unsigned(for: 20) { + guard helperEpoch < UInt64.max, proposed == helperEpoch + 1 else { + throw DiagnosticsProtocolError.invalidMessage + } + } + if let reason = message.value.unsigned(for: 25), !(1...4).contains(reason) { + throw DiagnosticsProtocolError.invalidMessage + } + if let origin = message.value.unsigned(for: 27), !(1...2).contains(origin) { + throw DiagnosticsProtocolError.invalidMessage + } + if let kind = message.value.unsigned(for: 29), !(1...3).contains(kind) { + throw DiagnosticsProtocolError.invalidMessage + } + for (publicLabel, idLabel) in [(7, 8), (9, 10), (11, 12), (13, 14)] { + let publicKey = message.value.bytes(for: UInt64(publicLabel), count: 32) + let keyID = message.value.bytes(for: UInt64(idLabel), count: 32) + guard (publicKey == nil) == (keyID == nil), + publicKey == nil || keyID == DiagnosticsCrypto.keyID(publicKey: publicKey!) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + } + + private static func expectedLabels(type: MessageType, template: DiagnosticsCBORValue) throws -> [UInt64] { + switch type { + case .qr: + return Array(1...17) + [24] + case .appRequest: + return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21, 23, 24, 255] + case .helperAccept: + return [1, 2, 3, 4, 5, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 22, 23, 24, 25, 255] + case .finalize, .finalizeAck, .receipt, .readyAck, .activate, .activeAck, .abort, .abortAck: + return [1, 2, 3, 4, 5, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 22, 23, 24, 25, 26, 255] + case .appKeyRotationRequest: + return lifecycleLabels([9, 10, 18]) + case .appKeyRotationNewProof, .appKeyRotationAccept: + return lifecycleLabels([9, 10, 18, 24]) + case .helperKeyRotationPropose: + return lifecycleLabels([13, 14, 20]) + case .helperKeyRotationNewProof, .helperKeyRotationConfirm: + return lifecycleLabels([13, 14, 20, 24]) + case .tlsPinRotationPropose: + return lifecycleLabels([16]) + case .tlsPinRotationConfirm: + return lifecycleLabels([16, 24]) + case .revocationRequest: + return lifecycleLabels([18, 25, 27]) + case .revocationRecord: + guard let origin = template.unsigned(for: 27) else { throw DiagnosticsProtocolError.invalidMessage } + return lifecycleLabels(origin == 1 ? [18, 24, 25, 27] : [18, 25, 27]) + case .lifecycleFinalize, .lifecycleActiveAck, .lifecycleAbort, .lifecycleAbortAck: + guard let rawKind = template.unsigned(for: 29), let kind = TransitionKind(rawValue: rawKind) else { + throw DiagnosticsProtocolError.invalidMessage + } + switch kind { + case .appKey: return lifecycleLabels([9, 10, 18, 24, 28, 29]) + case .helperKey: return lifecycleLabels([13, 14, 20, 24, 28, 29]) + case .tlsPin: return lifecycleLabels([16, 24, 28, 29]) + } + } + } + + private static func lifecycleLabels(_ additional: [UInt64]) -> [UInt64] { + ([1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 15, 17, 19, 21, 22, 23, 26, 255] + additional).sorted() + } + + private static func signerPublicKeyLabel(for message: Message) -> UInt64? { + switch message.type { + case .appRequest, .finalize, .receipt, .activate, .abort: return 18 + case .helperAccept, .finalizeAck, .readyAck, .activeAck, .abortAck: return 9 + case .appKeyRotationRequest, .helperKeyRotationConfirm, .tlsPinRotationConfirm, .revocationRequest, .lifecycleAbort: return 7 + case .appKeyRotationNewProof: return 9 + case .appKeyRotationAccept, .helperKeyRotationPropose, .tlsPinRotationPropose, .revocationRecord, .lifecycleAbortAck: return 11 + case .helperKeyRotationNewProof: return 13 + case .lifecycleFinalize: + return message.value.unsigned(for: 29) == TransitionKind.appKey.rawValue ? 9 : 7 + case .lifecycleActiveAck: + return message.value.unsigned(for: 29) == TransitionKind.helperKey.rawValue ? 13 : 11 + case .qr: return nil + } + } + + private static func domain(for type: MessageType) -> String? { + let suffix: String + switch type { + case .qr: return nil + case .appRequest: suffix = "app-request" + case .helperAccept: suffix = "helper-accept" + case .finalize: suffix = "pairing-finalize" + case .finalizeAck: suffix = "pairing-finalize-ack" + case .receipt: suffix = "pairing-receipt" + case .readyAck: suffix = "pairing-ready-ack" + case .activate: suffix = "pairing-activate" + case .activeAck: suffix = "pairing-active-ack" + case .abort: suffix = "pairing-abort" + case .abortAck: suffix = "pairing-abort-ack" + case .appKeyRotationRequest: suffix = "app-key-rotation-request" + case .appKeyRotationNewProof: suffix = "app-key-rotation-new-proof" + case .appKeyRotationAccept: suffix = "app-key-rotation-accept" + case .helperKeyRotationPropose: suffix = "helper-key-rotation-propose" + case .helperKeyRotationNewProof: suffix = "helper-key-rotation-new-proof" + case .helperKeyRotationConfirm: suffix = "helper-key-rotation-confirm" + case .tlsPinRotationPropose: suffix = "tls-pin-rotation-propose" + case .tlsPinRotationConfirm: suffix = "tls-pin-rotation-confirm" + case .revocationRequest: suffix = "revocation-request" + case .revocationRecord: suffix = "revocation-record" + case .lifecycleFinalize: suffix = "lifecycle-finalize" + case .lifecycleActiveAck: suffix = "lifecycle-active-ack" + case .lifecycleAbort: suffix = "lifecycle-abort" + case .lifecycleAbortAck: suffix = "lifecycle-abort-ack" + } + return "eu.vaultsync.helper-pairing/v1/\(suffix)\0" + } + + static func validEndpointHost(_ host: String) -> Bool { + guard !host.isEmpty, host.utf8.count <= 253, host == host.lowercased(), + host.utf8.allSatisfy({ $0 >= 0x21 && $0 <= 0x7e }), + !host.hasPrefix("."), !host.hasSuffix("."), + !host.contains(":"), + !host.utf8.allSatisfy({ ($0 >= 0x30 && $0 <= 0x39) || $0 == 0x2e }) else { + // IPv6 and canonical IPv4 literals are validated by URLComponents + // in the transport; this branch accepts only canonical DNS names. + return DiagnosticsPinnedTransport.isCanonicalIPAddress(host) + } + return host.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { label in + !label.isEmpty && label.utf8.count <= 63 && !label.hasPrefix("-") && !label.hasSuffix("-") && + label.utf8.allSatisfy { + ($0 >= 0x61 && $0 <= 0x7a) || ($0 >= 0x30 && $0 <= 0x39) || $0 == 0x2d + } + } + } + + private static func unixSeconds(_ date: Date) throws -> UInt64 { + let seconds = date.timeIntervalSince1970.rounded(.down) + guard seconds >= 0, seconds < Double(UInt64.max) else { + throw DiagnosticsProtocolError.invalidMessage + } + return UInt64(seconds) + } + + static func checkedAdding(_ value: UInt64, _ increment: UInt64) throws -> UInt64 { + let result = value.addingReportingOverflow(increment) + guard !result.overflow else { throw DiagnosticsProtocolError.invalidMessage } + return result.partialValue + } +} diff --git a/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift b/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift new file mode 100644 index 0000000..02d4943 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift @@ -0,0 +1,249 @@ +import Darwin +import Foundation +import Security + +protocol DiagnosticsTransporting: Sendable { + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? +} + +final class DiagnosticsPinnedTransport: DiagnosticsTransporting, @unchecked Sendable { + private let endpoint: URL + private let host: String + private let pin: Data + + init(host: String, port: UInt16, pin: Data) throws { + guard pin.count == 32, + !host.isEmpty, + port > 0 else { + throw DiagnosticsProtocolError.invalidMessage + } + var components = URLComponents() + components.scheme = "https" + components.host = host + components.port = Int(port) + components.path = "/" + guard let endpoint = components.url, + endpoint.scheme == "https", + endpoint.host == host, + endpoint.port == Int(port), + endpoint.user == nil, + endpoint.password == nil, + endpoint.query == nil, + endpoint.fragment == nil else { + throw DiagnosticsProtocolError.invalidMessage + } + self.endpoint = endpoint + self.host = host + self.pin = pin + } + + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { + let allowedPaths = [ + DiagnosticsPairingProtocol.path, + DiagnosticsCapabilityProtocol.path, + DiagnosticsNamespaceProtocol.enablementPath, + DiagnosticsNamespaceProtocol.authorizationPath, + ] + guard allowedPaths.contains(path), + !body.isEmpty, body.count <= DiagnosticsDeterministicCBOR.maximumMessageBytes else { + throw DiagnosticsProtocolError.invalidMessage + } + let url = endpoint.appending(path: String(path.dropFirst()), directoryHint: .notDirectory) + guard url.host == host, url.query == nil, url.fragment == nil else { + throw DiagnosticsProtocolError.invalidMessage + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 15 + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.setValue("application/cbor", forHTTPHeaderField: "Content-Type") + request.setValue("no-store", forHTTPHeaderField: "Cache-Control") + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + + let configuration = URLSessionConfiguration.ephemeral + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.httpAdditionalHeaders = nil + configuration.waitsForConnectivity = false + configuration.tlsMinimumSupportedProtocolVersion = .TLSv13 + configuration.tlsMaximumSupportedProtocolVersion = .TLSv13 + let delegate = PinnedSessionDelegate(expectedHost: host, expectedPin: pin) + let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + + var data = Data() + let response: URLResponse + do { + let (bytes, receivedResponse) = try await session.bytes(for: request) + response = receivedResponse + for try await byte in bytes { + guard data.count < DiagnosticsDeterministicCBOR.maximumMessageBytes else { + throw DiagnosticsProtocolError.invalidMessage + } + data.append(byte) + } + } catch let error as DiagnosticsProtocolError { + throw error + } catch { + throw DiagnosticsProtocolError.unavailable + } + guard delegate.didAuthenticateServer, + let http = response as? HTTPURLResponse, + http.url?.host == host, + http.value(forHTTPHeaderField: "Content-Encoding") == nil else { + throw DiagnosticsProtocolError.unavailable + } + switch http.statusCode { + case 200: + guard responseBody, + !data.isEmpty, + data.count <= DiagnosticsDeterministicCBOR.maximumMessageBytes, + http.value(forHTTPHeaderField: "Content-Type") == "application/cbor", + Self.contentLength(http) == data.count else { + throw DiagnosticsProtocolError.invalidMessage + } + return data + case 202: + guard !responseBody, data.isEmpty, Self.contentLength(http) == 0, + http.value(forHTTPHeaderField: "Content-Type") == nil else { + throw DiagnosticsProtocolError.invalidMessage + } + return nil + case 400: + throw DiagnosticsProtocolError.invalidMessage + case 404: + throw DiagnosticsProtocolError.unavailable + case 409: + throw DiagnosticsProtocolError.conflict + case 429: + throw DiagnosticsProtocolError.rateLimited + default: + throw DiagnosticsProtocolError.unavailable + } + } + + static func isCanonicalIPAddress(_ host: String) -> Bool { + var ipv4 = in_addr() + if host.withCString({ inet_pton(AF_INET, $0, &ipv4) }) == 1 { + return canonicalString(family: AF_INET, address: &ipv4) == host + } + var ipv6 = in6_addr() + if host.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 { + return canonicalString(family: AF_INET6, address: &ipv6) == host + } + return false + } + + private static func canonicalString(family: Int32, address: inout T) -> String? { + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + let result = withUnsafePointer(to: &address) { pointer in + inet_ntop(family, UnsafeRawPointer(pointer), &buffer, socklen_t(buffer.count)) + } + guard result != nil else { return nil } + let end = buffer.firstIndex(of: 0) ?? buffer.endIndex + return String(decoding: buffer[.. Int? { + guard let raw = response.value(forHTTPHeaderField: "Content-Length"), + let value = Int(raw), value >= 0 else { return nil } + return value + } +} + +private final class PinnedSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, @unchecked Sendable { + private let expectedHost: String + private let expectedPin: Data + private let lock = NSLock() + private var authenticated = false + + init(expectedHost: String, expectedPin: Data) { + self.expectedHost = expectedHost + self.expectedPin = expectedPin + } + + var didAuthenticateServer: Bool { + lock.lock() + defer { lock.unlock() } + return authenticated + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + challenge.protectionSpace.host == expectedHost, + let trust = challenge.protectionSpace.serverTrust, + let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let certificate = chain.first, + let key = SecCertificateCopyKey(certificate), + let attributes = SecKeyCopyAttributes(key) as? [String: Any], + attributes[kSecAttrKeyType as String] as? String == kSecAttrKeyTypeECSECPrimeRandom as String, + attributes[kSecAttrKeySizeInBits as String] as? Int == 256, + let external = SecKeyCopyExternalRepresentation(key, nil) as Data?, + external.count == 65, + external.first == 0x04 else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + // DER SubjectPublicKeyInfo prefix for id-ecPublicKey + prime256v1, + // followed by the 65-byte uncompressed P-256 public point. + var spki = Data([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, + 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, + 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]) + spki.append(external) + let actualPin = DiagnosticsCrypto.sha256(spki) + guard Self.constantTimeEqual(actualPin, expectedPin) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + guard DiagnosticsPinnedTrustEvaluator.evaluate(trust: trust, leaf: certificate) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + lock.lock() + authenticated = true + lock.unlock() + completionHandler(.useCredential, URLCredential(trust: trust)) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } + + private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for (left, right) in zip(lhs, rhs) { + difference |= left ^ right + } + return difference == 0 + } +} + +private enum DiagnosticsPinnedTrustEvaluator { + static func evaluate(trust: SecTrust, leaf: SecCertificate) -> Bool { + // The exact out-of-band pinned leaf is the sole challenge-scoped anchor. + // URLSession's SSL policy continues to enforce the requested host and validity. + guard SecTrustSetAnchorCertificates(trust, [leaf] as CFArray) == errSecSuccess, + SecTrustSetAnchorCertificatesOnly(trust, true) == errSecSuccess, + SecTrustSetNetworkFetchAllowed(trust, false) == errSecSuccess else { + return false + } + var error: CFError? + return SecTrustEvaluateWithError(trust, &error) + } +} diff --git a/ios/VaultSync/Views/ControlledDiagnosticsView.swift b/ios/VaultSync/Views/ControlledDiagnosticsView.swift new file mode 100644 index 0000000..8d556cf --- /dev/null +++ b/ios/VaultSync/Views/ControlledDiagnosticsView.swift @@ -0,0 +1,584 @@ +import SwiftUI + +struct ControlledDiagnosticsView: View { + let syncthingManager: SyncthingManager + + @State private var controller = DiagnosticsPairingController() + @State private var selectedDeviceID = "" + @State private var selectedFolderID = "" + @State private var pastedInvitation = "" + @State private var showScanner = false + @State private var showConsent = false + @State private var consentAction: ConsentAction = .scan + @State private var showRecoveryConfirmation = false + @State private var missingFolderRecordID: String? + + private enum ConsentAction { + case scan + case paste + } + + var body: some View { + List { + explanationSection + if controller.notice == .recoveryRequired { + recoverySection + } + existingPairingsSection + newPairingSection + compatibilitySection + } + .navigationTitle(L10n.tr("Controlled Diagnostics")) + .navigationBarTitleDisplayMode(.inline) + .task { + controller.refresh() + chooseInitialTarget() + } + .onChange(of: selectedDeviceID) { _, _ in + if !eligibleFolders.contains(where: { $0.id == selectedFolderID }) { + selectedFolderID = eligibleFolders.first?.id ?? "" + } + } + .sheet(isPresented: $showScanner) { + QRScannerView( + title: L10n.tr("Scan Helper Pairing QR"), + deniedMessage: L10n.tr("VaultSync needs camera access only to scan the helper pairing QR you chose. You can paste the invitation instead."), + unavailableMessage: L10n.tr("The camera could not be started. Return and paste the helper pairing invitation instead."), + manualButtonTitle: L10n.tr("Paste Invitation Instead") + ) { code in + Task { await startPairing(with: code) } + } + } + .alert(L10n.tr("Authorize diagnostics pairing?"), isPresented: $showConsent) { + Button(L10n.tr("Cancel"), role: .cancel) { } + Button(L10n.tr("Continue")) { + switch consentAction { + case .scan: + showScanner = true + case .paste: + let invitation = pastedInvitation + pastedInvitation = "" + Task { await startPairing(with: invitation) } + } + } + } message: { + Text(L10n.tr("Pairing authorizes only controlled diagnostics for the selected server and folder. It does not create a namespace, transfer files, change Syncthing trust, or use Cloud Relay. Namespace setup remains a separate explicit app and operator action.")) + } + .alert(L10n.tr("Reset local diagnostics credentials?"), isPresented: $showRecoveryConfirmation) { + Button(L10n.tr("Cancel"), role: .cancel) { } + Button(L10n.tr("Reset and Re-pair"), role: .destructive) { + controller.resetOrphanedCredentialsForRepair() + } + } message: { + Text(L10n.tr("This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint.")) + } + } + + private var explanationSection: some View { + Section { + Label(L10n.tr("Explicit local or VPN pairing only"), systemImage: "lock.shield") + Label(L10n.tr("TLS 1.3 with an exact QR-pinned key"), systemImage: "checkmark.seal") + Label(L10n.tr("No discovery, trust adoption, Relay tunnel, or automatic namespace"), systemImage: "hand.raised") + Text(L10n.tr("Pairing and capability checks create no upload, download, or roundtrip evidence. A later diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones.")) + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text(L10n.tr("Security boundary")) + } + } + + @ViewBuilder + private var recoverySection: some View { + Section { + Label(L10n.tr("Re-pair required"), systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(Color.statusAttention) + Text(L10n.tr("Protected app storage and the diagnostics Keychain no longer match. VaultSync will not reuse the surviving key automatically.")) + .font(.caption) + .foregroundStyle(.secondary) + Button(L10n.tr("Reset Local Credentials"), role: .destructive) { + showRecoveryConfirmation = true + } + } header: { + Text(L10n.tr("Recovery")) + } + } + + @ViewBuilder + private var existingPairingsSection: some View { + Section { + if controller.records.isEmpty { + Text(L10n.tr("No diagnostics helper is paired.")) + .foregroundStyle(.secondary) + } else { + ForEach(controller.records) { record in + pairingRow(record) + } + } + } header: { + Text(L10n.tr("Paired targets")) + } + } + + private func pairingRow(_ record: DiagnosticsPairingRecord) -> some View { + VStack(alignment: .leading, spacing: VaultSpacing.s) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { + Text(deviceName(record.homeserverDeviceID)) + .font(.headline) + Text(folderName(record.folderID)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Text(stateLabel(record.state)) + .font(.caption.weight(.semibold)) + .foregroundStyle(stateColor(record.state)) + } + + if record.state == .acceptanceReceived, let fingerprint = record.transcriptFingerprint { + Text(L10n.tr("Compare this fingerprint with the helper operator:")) + .font(.caption) + Text(fingerprint) + .font(.system(.title3, design: .monospaced).weight(.bold)) + .accessibilityLabel(L10n.fmt("Pairing fingerprint %@", fingerprint)) + Button(L10n.tr("Fingerprint Matches — Activate")) { + Task { await controller.confirmFingerprintAndActivate(recordID: record.id) } + } + .buttonStyle(.borderedProminent) + } + + preparedPairingAction(record) + pendingCancellationActions(record) + activeActions(record) + + if missingFolderRecordID == record.id, folderPath(record.folderID) == nil { + Label( + L10n.tr("The selected folder was renamed or removed. Restore it in Syncthing before retrying."), + systemImage: "exclamationmark.folder" + ) + .font(.caption) + .foregroundStyle(Color.statusAttention) + } + + if let error = controller.lastError { + Label(errorLabel(error), systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(error == .unavailable ? Color.statusAttention : Color.statusError) + } + } + .padding(.vertical, VaultSpacing.xs) + } + + @ViewBuilder + private func preparedPairingAction(_ record: DiagnosticsPairingRecord) -> some View { + switch record.state { + case .requestPrepared, .finalizePrepared, .receiptPrepared, .activatePrepared: + Button(L10n.tr("Retry Exact Pairing Step")) { + Task { await controller.retryPreparedPairing(recordID: record.id) } + } + case .finalizeAcknowledged, .readyAcknowledged: + Button(L10n.tr("Continue Verified Pairing")) { + Task { await controller.confirmFingerprintAndActivate(recordID: record.id) } + } + case .lifecyclePending: + Button(L10n.tr("Continue Credential Rotation")) { + Task { await controller.continueLifecycle(recordID: record.id) } + } + Button(L10n.tr("Abort Pending Credential Rotation"), role: .destructive) { + Task { await controller.abortLifecycle(recordID: record.id) } + } + if controller.canDiscardExpiredLifecycle(record) { + Button(L10n.tr("Discard Expired Credential Rotation"), role: .destructive) { + controller.discardExpiredLifecycle(recordID: record.id) + } + } + case .revocationPrepared: + Button(L10n.tr("Retry Exact Revocation")) { + Task { await controller.retryRevocation(recordID: record.id) } + } + case .abortPrepared: + Button(L10n.tr("Retry Exact Pairing Cancellation")) { + Task { await controller.cancelPendingPairing(recordID: record.id) } + } + default: + EmptyView() + } + } + + @ViewBuilder + private func pendingCancellationActions(_ record: DiagnosticsPairingRecord) -> some View { + let cancellableStates: Set = [ + .requestPrepared, .acceptanceReceived, .finalizePrepared, + .finalizeAcknowledged, .receiptPrepared, .readyAcknowledged, + ] + if cancellableStates.contains(record.state) { + Button(L10n.tr("Cancel Pending Pairing"), role: .destructive) { + Task { await controller.cancelPendingPairing(recordID: record.id) } + } + } + if controller.canDiscardExpiredPairing(record) { + Button(L10n.tr("Discard Expired Pairing"), role: .destructive) { + controller.discardExpiredPendingPairing(recordID: record.id) + } + } + } + + @ViewBuilder + private func activeActions(_ record: DiagnosticsPairingRecord) -> some View { + if isOperational(record.state) { + let capability = controller.capabilityStates[record.id] ?? .notChecked + if isStableAuthorization(record.state) { + HStack { + Text(L10n.tr("Capability")) + .font(.caption) + Spacer() + Text(capabilityLabel(capability)) + .font(.caption.weight(.semibold)) + .foregroundStyle(capabilityColor(capability)) + } + Button(L10n.tr("Check Authenticated Capability")) { + Task { await controller.checkCapability(recordID: record.id) } + } + } + + namespaceAction(record, capability: capability) + + if isStableAuthorization(record.state) { + NavigationLink { + DiagnosticsCredentialMaintenanceView( + controller: controller, + recordID: record.id + ) + } label: { + Label(L10n.tr("Rotation, Revocation & Recovery"), systemImage: "key.horizontal") + } + } + } + } + + @ViewBuilder + private func namespaceAction( + _ record: DiagnosticsPairingRecord, + capability: DiagnosticsPairingController.CapabilityState + ) -> some View { + switch record.state { + case .active where capability == .available: + Button(L10n.tr("Request Diagnostics Namespace")) { + Task { await controller.requestNamespaceEnablement(recordID: record.id) } + } + case .namespaceEnablementPrepared, .namespaceAwaitingOperator, .namespaceAuthorizationPrepared: + Text(L10n.tr("The helper operator must explicitly create the exact “VaultSync Diagnostics” namespace for this folder. VaultSync never creates or adopts it automatically.")) + .font(.caption) + .foregroundStyle(.secondary) + Button(L10n.tr("Check Explicit Operator Step")) { + guard let path = folderPath(record.folderID) else { + missingFolderRecordID = record.id + return + } + missingFolderRecordID = nil + Task { await controller.continueNamespace(recordID: record.id, currentFolderPath: path) } + } + case .namespaceAuthorizationRefreshRequired, .namespaceAuthorizationRefreshPrepared: + Text(L10n.tr("The credential changed. The existing namespace remains unavailable until its next immutable authorization epoch is explicitly completed.")) + .font(.caption) + .foregroundStyle(Color.statusAttention) + Button(L10n.tr("Authorize Next Namespace Epoch")) { + guard let path = folderPath(record.folderID) else { + missingFolderRecordID = record.id + return + } + missingFolderRecordID = nil + Task { + await controller.continueNamespaceAuthorizationRefresh( + recordID: record.id, + currentFolderPath: path + ) + } + } + case .namespaceActive: + Label(L10n.tr("Namespace authorized — no transfer artifact created"), systemImage: "checkmark.shield") + .font(.caption) + .foregroundStyle(Color.statusSuccess) + default: + EmptyView() + } + } + + private var newPairingSection: some View { + Section { + if syncthingManager.devices.isEmpty || syncthingManager.folders.isEmpty { + Text(L10n.tr("Add the existing homeserver device and folder in Syncthing first.")) + .foregroundStyle(.secondary) + } else { + Picker(L10n.tr("Homeserver"), selection: $selectedDeviceID) { + ForEach(syncthingManager.devices) { device in + Text(device.name.isEmpty ? device.deviceID : device.name) + .tag(device.deviceID) + } + } + Picker(L10n.tr("Folder"), selection: $selectedFolderID) { + ForEach(eligibleFolders) { folder in + Text(folder.label.isEmpty ? folder.id : folder.label) + .tag(folder.id) + } + } + + Button { + consentAction = .scan + showConsent = true + } label: { + Label(L10n.tr("Scan Helper Pairing QR"), systemImage: "qrcode.viewfinder") + } + .disabled(selectedDeviceID.isEmpty || selectedFolderID.isEmpty) + + SecureField(L10n.tr("Paste pairing invitation"), text: $pastedInvitation) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .privacySensitive() + Button(L10n.tr("Use Pasted Invitation")) { + consentAction = .paste + showConsent = true + } + .disabled( + selectedDeviceID.isEmpty || selectedFolderID.isEmpty || pastedInvitation.isEmpty + ) + } + } header: { + Text(L10n.tr("New explicit pairing")) + } footer: { + Text(L10n.tr("The helper must first be configured by its operator for a private local, LAN, or VPN endpoint and an exact existing folder. Public defaults and automatic discovery are not supported.")) + } + } + + private var compatibilitySection: some View { + Section { + Text(L10n.tr("Old helper: capability unavailable; no pairing or namespace mutation.")) + Text(L10n.tr("New helper with an old app: diagnostics stays dormant.")) + Text(L10n.tr("Cloud Relay is not used for pairing, capability, namespace, or evidence.")) + } header: { + Text(L10n.tr("Compatibility")) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + private var eligibleFolders: [SyncthingManager.FolderInfo] { + syncthingManager.folders.filter { $0.deviceIDs.contains(selectedDeviceID) } + } + + private func chooseInitialTarget() { + if selectedDeviceID.isEmpty { + selectedDeviceID = syncthingManager.devices.first?.deviceID ?? "" + } + if selectedFolderID.isEmpty { + selectedFolderID = eligibleFolders.first?.id ?? "" + } + } + + private func startPairing(with invitation: String) async { + guard !invitation.isEmpty, !selectedDeviceID.isEmpty, !selectedFolderID.isEmpty else { return } + await controller.beginPairing( + qr: invitation, + homeserverDeviceID: selectedDeviceID, + folderID: selectedFolderID + ) + pastedInvitation = "" + } + + private func deviceName(_ id: String) -> String { + guard let device = syncthingManager.devices.first(where: { $0.deviceID == id }) else { + return L10n.tr("Unavailable homeserver") + } + return device.name.isEmpty ? id : device.name + } + + private func folderName(_ id: String) -> String { + guard let folder = syncthingManager.folders.first(where: { $0.id == id }) else { + return L10n.tr("Unavailable folder") + } + return folder.label.isEmpty ? id : folder.label + } + + private func folderPath(_ id: String) -> String? { + syncthingManager.folders.first(where: { $0.id == id })?.path + } + + private func isOperational(_ state: DiagnosticsPairingRecord.State) -> Bool { + [.active, .namespaceEnablementPrepared, .namespaceAwaitingOperator, + .namespaceAuthorizationPrepared, .namespaceActive, + .namespaceAuthorizationRefreshRequired, .namespaceAuthorizationRefreshPrepared].contains(state) + } + + private func isStableAuthorization(_ state: DiagnosticsPairingRecord.State) -> Bool { + state == .active || state == .namespaceActive + } + + private func stateLabel(_ state: DiagnosticsPairingRecord.State) -> String { + switch state { + case .requestPrepared, .finalizePrepared, .finalizeAcknowledged, + .receiptPrepared, .readyAcknowledged, .activatePrepared, .abortPrepared: + return L10n.tr("Pairing pending") + case .acceptanceReceived: return L10n.tr("Confirmation required") + case .active: return L10n.tr("Paired") + case .namespaceEnablementPrepared, .namespaceAwaitingOperator: + return L10n.tr("Operator action required") + case .namespaceAuthorizationPrepared, .namespaceAuthorizationRefreshPrepared: + return L10n.tr("Authorization pending") + case .namespaceAuthorizationRefreshRequired: + return L10n.tr("Reauthorization required") + case .namespaceActive: return L10n.tr("Namespace authorized") + case .lifecyclePending: return L10n.tr("Rotation pending") + case .revocationPrepared: return L10n.tr("Revocation pending") + case .revoked: return L10n.tr("Revoked") + } + } + + private func stateColor(_ state: DiagnosticsPairingRecord.State) -> Color { + switch state { + case .active, .namespaceActive: return Color.statusSuccess + case .revoked: return .secondary + case .requestPrepared, .finalizePrepared, .receiptPrepared, .activatePrepared, + .abortPrepared, .lifecyclePending, .revocationPrepared: + return Color.statusError + default: return Color.statusAttention + } + } + + private func capabilityLabel(_ state: DiagnosticsPairingController.CapabilityState) -> String { + switch state { + case .notChecked: return L10n.tr("Not checked") + case .checking: return L10n.tr("Checking…") + case .available: return L10n.tr("Available") + case .unavailable: return L10n.tr("Unavailable") + case .unsupported: return L10n.tr("Unsupported") + } + } + + private func capabilityColor(_ state: DiagnosticsPairingController.CapabilityState) -> Color { + switch state { + case .available: return Color.statusSuccess + case .unsupported: return Color.statusError + case .unavailable: return Color.statusAttention + default: return .secondary + } + } + + private func errorLabel(_ error: DiagnosticsProtocolError) -> String { + switch error { + case .invalidMessage: return L10n.tr("The authenticated protocol response was invalid.") + case .expired: return L10n.tr("The signed step expired. Start again with a fresh operator action.") + case .unavailable: return L10n.tr("Capability unavailable. Nothing was created or transferred.") + case .unsupported: return L10n.tr("This target is unsupported for controlled diagnostics.") + case .conflict: return L10n.tr("A conflicting immutable state was found. VaultSync did not adopt or overwrite it.") + case .rateLimited: return L10n.tr("The helper rate limit was reached. Wait before retrying the exact step.") + case .protectedDataUnavailable: return L10n.tr("Unlock this device to use protected diagnostics credentials.") + case .recoveryRequired: return L10n.tr("Re-pair required. Surviving credentials are never trusted automatically.") + } + } +} + +private struct DiagnosticsCredentialMaintenanceView: View { + @Bindable var controller: DiagnosticsPairingController + let recordID: String + + @State private var helperProposal = "" + @State private var helperProof = "" + @State private var tlsProposal = "" + @State private var showAppRotationConfirmation = false + @State private var showRevocationConfirmation = false + + var body: some View { + List { + Section { + Button(L10n.tr("Rotate App Signing Key")) { + showAppRotationConfirmation = true + } + Text(L10n.tr("The current app key authorizes the next key. The old key is not retired until the helper acknowledgement and a capability query under the new key both succeed.")) + .font(.caption) + .foregroundStyle(.secondary) + Text(L10n.tr("App-key rotations reuse one staged installation key across folder authorizations. Each folder advances separately, and another key generation stays blocked until all non-revoked authorizations catch up.")) + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text(L10n.tr("App key")) + } + + Section { + SecureField(L10n.tr("Helper rotation proposal"), text: $helperProposal) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .privacySensitive() + SecureField(L10n.tr("Helper new-key proof"), text: $helperProof) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .privacySensitive() + Button(L10n.tr("Validate and Confirm Helper Key")) { + let proposal = helperProposal + let proof = helperProof + helperProposal = "" + helperProof = "" + Task { + await controller.startHelperKeyRotation( + recordID: recordID, + proposal: proposal, + proof: proof + ) + } + } + .disabled(helperProposal.isEmpty || helperProof.isEmpty) + } header: { + Text(L10n.tr("Helper key")) + } footer: { + Text(L10n.tr("Paste the two values from the explicit local helper-admin rotation. Both old- and new-key signatures must validate.")) + } + + Section { + SecureField(L10n.tr("TLS pin rotation proposal"), text: $tlsProposal) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .privacySensitive() + Button(L10n.tr("Validate and Confirm TLS Pin")) { + let proposal = tlsProposal + tlsProposal = "" + Task { + await controller.startTLSPinRotation(recordID: recordID, proposal: proposal) + } + } + .disabled(tlsProposal.isEmpty) + } header: { + Text(L10n.tr("TLS pin")) + } footer: { + Text(L10n.tr("A certificate renewal with the same pinned key needs no change. A new TLS key requires this signed transition.")) + } + + Section { + Button(L10n.tr("Revoke This App Authorization"), role: .destructive) { + showRevocationConfirmation = true + } + Text(L10n.tr("If this app key is lost, pair the replacement as a new installation and revoke the old fingerprint locally on the helper. There is no cloud escrow or trust recovery.")) + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text(L10n.tr("Revocation and recovery")) + } + } + .navigationTitle(L10n.tr("Credential Maintenance")) + .navigationBarTitleDisplayMode(.inline) + .alert(L10n.tr("Rotate the app signing key?"), isPresented: $showAppRotationConfirmation) { + Button(L10n.tr("Cancel"), role: .cancel) { } + Button(L10n.tr("Rotate")) { + Task { await controller.startAppKeyRotation(recordID: recordID) } + } + } message: { + Text(L10n.tr("This is scoped to the selected app, homeserver, and folder. Any authorized namespace will require a new immutable authorization epoch.")) + } + .alert(L10n.tr("Revoke this app authorization?"), isPresented: $showRevocationConfirmation) { + Button(L10n.tr("Cancel"), role: .cancel) { } + Button(L10n.tr("Revoke"), role: .destructive) { + Task { await controller.revoke(recordID: recordID, reason: .userRequest) } + } + } message: { + Text(L10n.tr("Revocation immediately prevents new controlled diagnostics operations for this exact authorization. It does not delete Syncthing data, namespace history, backups, versions, conflicts, or tombstones.")) + } + } +} diff --git a/ios/VaultSync/Views/QRScannerView.swift b/ios/VaultSync/Views/QRScannerView.swift index 0218cfc..c13bfe6 100644 --- a/ios/VaultSync/Views/QRScannerView.swift +++ b/ios/VaultSync/Views/QRScannerView.swift @@ -5,6 +5,10 @@ import UIKit struct QRScannerView: View { let onScan: (String) -> Void + let title: String + let deniedMessage: String + let unavailableMessage: String + let manualButtonTitle: String @Environment(\.dismiss) private var dismiss @State private var cameraPermission: AVAuthorizationStatus = .notDetermined /// Camera permission granted but the capture session could not be built @@ -12,6 +16,20 @@ struct QRScannerView: View { /// Without this the sheet stayed a silent black screen (#95). @State private var cameraSetupFailed = false + init( + title: String = L10n.tr("Scan QR Code"), + deniedMessage: String = L10n.tr("VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings."), + unavailableMessage: String = L10n.tr("The camera could not be started on this device. Enter the Device ID manually instead — in Syncthing on your computer, choose Actions → Show ID."), + manualButtonTitle: String = L10n.tr("Enter Device ID Manually"), + onScan: @escaping (String) -> Void + ) { + self.title = title + self.deniedMessage = deniedMessage + self.unavailableMessage = unavailableMessage + self.manualButtonTitle = manualButtonTitle + self.onScan = onScan + } + var body: some View { NavigationStack { Group { @@ -37,7 +55,7 @@ struct QRScannerView: View { ProgressView("Requesting camera access…") } } - .navigationTitle("Scan QR Code") + .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -63,7 +81,7 @@ struct QRScannerView: View { Text("Camera Access Required") .font(.title3.bold()) - Text("VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings.") + Text(deniedMessage) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -90,13 +108,13 @@ struct QRScannerView: View { Text(L10n.tr("Camera Unavailable")) .font(.title3.bold()) - Text(L10n.tr("The camera could not be started on this device. Enter the Device ID manually instead — in Syncthing on your computer, choose Actions → Show ID.")) + Text(unavailableMessage) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, VaultSpacing.xl) - Button(L10n.tr("Enter Device ID Manually")) { + Button(manualButtonTitle) { dismiss() } .buttonStyle(.borderedProminent) @@ -149,16 +167,16 @@ private final class ScannerCoordinator: NSObject, AVCaptureMetadataOutputObjects didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection ) { - let alreadyScanned = didScan.withLock { scanned -> Bool in - if scanned { return true } - scanned = true - return false - } - guard !alreadyScanned, - let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, object.type == .qr, let value = object.stringValue, !value.isEmpty else { return } + let shouldHandle = didScan.withLock { scanned -> Bool in + if scanned { return false } + scanned = true + return true + } + guard shouldHandle else { return } captureSession.stopRunning() DispatchQueue.main.async { [onScan] in diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index 01f6887..3331409 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -68,6 +68,16 @@ struct SettingsView: View { Text(L10n.tr("Check setup progress and troubleshooting tips.")) } + Section { + NavigationLink { + ControlledDiagnosticsView(syncthingManager: syncthingManager) + } label: { + Label(L10n.tr("Controlled Diagnostics"), systemImage: "stethoscope") + } + } footer: { + Text(L10n.tr("Optional, explicit helper pairing and namespace authorization. Nothing is created on upgrade or without your action.")) + } + } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) diff --git a/ios/VaultSync/de.lproj/InfoPlist.strings b/ios/VaultSync/de.lproj/InfoPlist.strings index 85cf2f1..9cf9382 100644 --- a/ios/VaultSync/de.lproj/InfoPlist.strings +++ b/ios/VaultSync/de.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; -"NSCameraUsageDescription" = "VaultSync verwendet die Kamera, um Syncthing-Geräte-QR-Codes für eine einfache Einrichtung zu scannen."; +"NSCameraUsageDescription" = "VaultSync verwendet die Kamera, um von dir ausdrücklich ausgewählte Einrichtungs- und Pairing-QR-Codes zu scannen."; "NSLocalNetworkUsageDescription" = "VaultSync verbindet sich direkt mit deinen anderen Geräten im selben Netzwerk, damit deine Vaults sofort synchronisieren – ohne Umweg über das Internet."; diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index 3a3cbd4..721b900 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -810,3 +810,107 @@ /* Dormante M4-Einwilligung; kein Produktfluss verwendet diesen Schlüssel. */ "Enable VaultSync Diagnostics for homeserver “%@” and vault “%@”? After separate confirmation by the local operator, this creates the visible app-owned folder “VaultSync Diagnostics”. It contains only opaque random protocol data, public-key identifiers, signatures, hashes, and expiry metadata—never note content or user-derived filenames. The folder, temporary files, and deletion tombstones synchronize and can be visible in Obsidian, Files, and on every configured peer. Backups, Syncthing versioning, remote history, and tombstones may retain artifacts after live cleanup. Disabling stops new operations but does not automatically delete the root, credentials, peer copies, backups, versions, or tombstones." = "VaultSync Diagnostics für Homeserver “%@” und Vault “%@” aktivieren? Nach einer getrennten Bestätigung durch die lokale betreibende Person wird der sichtbare app-eigene Ordner “VaultSync Diagnostics” erstellt. Er enthält ausschließlich undurchsichtige zufällige Protokolldaten, öffentliche Schlüsselkennungen, Signaturen, Hashes und Ablaufmetadaten—niemals Notizinhalte oder aus Benutzerdaten abgeleitete Dateinamen. Der Ordner, temporäre Dateien und Lösch-Tombstones werden synchronisiert und können in Obsidian, Dateien und auf jedem eingerichteten Peer sichtbar sein. Backups, Syncthing-Versionierung, Remote-Verlauf und Tombstones können Artefakte nach der Live-Bereinigung aufbewahren. Das Deaktivieren stoppt neue Vorgänge, löscht aber nicht automatisch den Stammordner, Zugangsdaten, Peer-Kopien, Backups, Versionen oder Tombstones."; +/* Kontrollierte Diagnose */ +"A certificate renewal with the same pinned key needs no change. A new TLS key requires this signed transition." = "Eine Zertifikatserneuerung mit demselben angehefteten Schlüssel erfordert keine Änderung. Ein neuer TLS-Schlüssel erfordert diesen signierten Übergang."; +"A conflicting immutable state was found. VaultSync did not adopt or overwrite it." = "Ein widersprüchlicher unveränderlicher Zustand wurde gefunden. VaultSync hat ihn weder übernommen noch überschrieben."; +"Add the existing homeserver device and folder in Syncthing first." = "Füge zuerst das vorhandene Homeserver-Gerät und den Ordner in Syncthing hinzu."; +"App key" = "App-Schlüssel"; +"App-key rotations reuse one staged installation key across folder authorizations. Each folder advances separately, and another key generation stays blocked until all non-revoked authorizations catch up." = "App-Schlüsselrotationen verwenden für alle Ordnerautorisierungen denselben vorgemerkten Installationsschlüssel. Jeder Ordner wird separat umgestellt; eine weitere Schlüsselgeneration bleibt gesperrt, bis alle nicht widerrufenen Autorisierungen aufgeholt haben."; +"Authorization pending" = "Autorisierung ausstehend"; +"Authorize Next Namespace Epoch" = "Nächste Namespace-Epoche autorisieren"; +"Authorize diagnostics pairing?" = "Diagnose-Pairing autorisieren?"; +"Available" = "Verfügbar"; +"Capability" = "Fähigkeit"; +"Capability unavailable. Nothing was created or transferred." = "Fähigkeit nicht verfügbar. Es wurde nichts erstellt oder übertragen."; +"Check Authenticated Capability" = "Authentisierte Fähigkeit prüfen"; +"Check Explicit Operator Step" = "Expliziten Operator-Schritt prüfen"; +"Checking…" = "Wird geprüft …"; +"Cloud Relay is not used for pairing, capability, namespace, or evidence." = "Cloud Relay wird nicht für Pairing, Fähigkeit, Namespace oder Nachweise verwendet."; +"Compare this fingerprint with the helper operator:" = "Vergleiche diesen Fingerabdruck mit dem Helper-Operator:"; +"Compatibility" = "Kompatibilität"; +"Confirmation required" = "Bestätigung erforderlich"; +"Continue" = "Fortfahren"; +"Continue Credential Rotation" = "Credential-Rotation fortsetzen"; +"Abort Pending Credential Rotation" = "Ausstehende Credential-Rotation abbrechen"; +"Discard Expired Credential Rotation" = "Abgelaufene Credential-Rotation verwerfen"; +"Continue Verified Pairing" = "Verifiziertes Pairing fortsetzen"; +"Controlled Diagnostics" = "Kontrollierte Diagnose"; +"Credential Maintenance" = "Credential-Verwaltung"; +"Explicit local or VPN pairing only" = "Nur explizites lokales oder VPN-Pairing"; +"Fingerprint Matches — Activate" = "Fingerabdruck stimmt überein — aktivieren"; +"Folder" = "Ordner"; +"Helper key" = "Helper-Schlüssel"; +"Helper new-key proof" = "Nachweis des neuen Helper-Schlüssels"; +"Helper rotation proposal" = "Vorschlag zur Helper-Rotation"; +"Homeserver" = "Homeserver"; +"If this app key is lost, pair the replacement as a new installation and revoke the old fingerprint locally on the helper. There is no cloud escrow or trust recovery." = "Wenn dieser App-Schlüssel verloren geht, paire den Ersatz als neue Installation und widerrufe den alten Fingerabdruck lokal am Helper. Es gibt weder Cloud-Hinterlegung noch Vertrauenswiederherstellung."; +"Namespace authorized" = "Namespace autorisiert"; +"Namespace authorized — no transfer artifact created" = "Namespace autorisiert — kein Transferartefakt erstellt"; +"New explicit pairing" = "Neues explizites Pairing"; +"New helper with an old app: diagnostics stays dormant." = "Neuer Helper mit alter App: Die Diagnose bleibt inaktiv."; +"No diagnostics helper is paired." = "Kein Diagnose-Helper ist gepairt."; +"No discovery, trust adoption, Relay tunnel, or automatic namespace" = "Keine Discovery, Vertrauensübernahme, Relay-Tunnel oder automatische Namespaces"; +"Old helper: capability unavailable; no pairing or namespace mutation." = "Alter Helper: Fähigkeit nicht verfügbar; keine Pairing- oder Namespace-Änderung."; +"Operator action required" = "Operator-Aktion erforderlich"; +"Optional, explicit helper pairing and namespace authorization. Nothing is created on upgrade or without your action." = "Optionales, explizites Helper-Pairing und Namespace-Autorisierung. Beim Upgrade oder ohne deine Aktion wird nichts erstellt."; +"Paired" = "Gepairt"; +"Paired targets" = "Gepairte Ziele"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. A later diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "Pairing und Fähigkeitsprüfungen erzeugen keinen Upload-, Download- oder Roundtrip-Nachweis. Ein späterer Diagnose-Namespace ist für synchronisierte Peers sichtbar und kann in Backups, Versionen, Konfliktkopien und Tombstones verbleiben."; +"Pairing authorizes only controlled diagnostics for the selected server and folder. It does not create a namespace, transfer files, change Syncthing trust, or use Cloud Relay. Namespace setup remains a separate explicit app and operator action." = "Das Pairing autorisiert nur kontrollierte Diagnose für den ausgewählten Server und Ordner. Es erstellt keinen Namespace, überträgt keine Dateien, ändert kein Syncthing-Vertrauen und nutzt Cloud Relay nicht. Die Namespace-Einrichtung bleibt eine separate explizite App- und Operator-Aktion."; +"Pairing fingerprint %@" = "Pairing-Fingerabdruck %@"; +"Pairing pending" = "Pairing ausstehend"; +"Paste Invitation Instead" = "Stattdessen Einladung einfügen"; +"Paste pairing invitation" = "Pairing-Einladung einfügen"; +"Paste the two values from the explicit local helper-admin rotation. Both old- and new-key signatures must validate." = "Füge die beiden Werte der expliziten lokalen Helper-Admin-Rotation ein. Sowohl die Signatur des alten als auch des neuen Schlüssels muss gültig sein."; +"Protected app storage and the diagnostics Keychain no longer match. VaultSync will not reuse the surviving key automatically." = "Geschützter App-Speicher und Diagnose-Schlüsselbund stimmen nicht mehr überein. VaultSync verwendet den verbliebenen Schlüssel nicht automatisch wieder."; +"Re-pair required" = "Erneutes Pairing erforderlich"; +"Re-pair required. Surviving credentials are never trusted automatically." = "Erneutes Pairing erforderlich. Verbliebenen Credentials wird nie automatisch vertraut."; +"Reauthorization required" = "Erneute Autorisierung erforderlich"; +"Recovery" = "Wiederherstellung"; +"Request Diagnostics Namespace" = "Diagnose-Namespace anfordern"; +"Reset Local Credentials" = "Lokale Credentials zurücksetzen"; +"Reset and Re-pair" = "Zurücksetzen und neu pairen"; +"Reset local diagnostics credentials?" = "Lokale Diagnose-Credentials zurücksetzen?"; +"Retry Exact Pairing Step" = "Exakten Pairing-Schritt wiederholen"; +"Retry Exact Revocation" = "Exakten Widerruf wiederholen"; +"Revocation and recovery" = "Widerruf und Wiederherstellung"; +"Revocation immediately prevents new controlled diagnostics operations for this exact authorization. It does not delete Syncthing data, namespace history, backups, versions, conflicts, or tombstones." = "Der Widerruf verhindert sofort neue kontrollierte Diagnosevorgänge für genau diese Autorisierung. Er löscht keine Syncthing-Daten, Namespace-Historie, Backups, Versionen, Konflikte oder Tombstones."; +"Revocation pending" = "Widerruf ausstehend"; +"Revoke" = "Widerrufen"; +"Revoke This App Authorization" = "Diese App-Autorisierung widerrufen"; +"Revoke this app authorization?" = "Diese App-Autorisierung widerrufen?"; +"Revoked" = "Widerrufen"; +"Rotate" = "Rotieren"; +"Rotate App Signing Key" = "App-Signaturschlüssel rotieren"; +"Rotate the app signing key?" = "App-Signaturschlüssel rotieren?"; +"Rotation pending" = "Rotation ausstehend"; +"Rotation, Revocation & Recovery" = "Rotation, Widerruf und Wiederherstellung"; +"Scan Helper Pairing QR" = "Helper-Pairing-QR scannen"; +"Security boundary" = "Sicherheitsgrenze"; +"TLS 1.3 with an exact QR-pinned key" = "TLS 1.3 mit exakt per QR angeheftetem Schlüssel"; +"TLS pin" = "TLS-Pin"; +"TLS pin rotation proposal" = "Vorschlag zur TLS-Pin-Rotation"; +"The authenticated protocol response was invalid." = "Die authentisierte Protokollantwort war ungültig."; +"The camera could not be started. Return and paste the helper pairing invitation instead." = "Die Kamera konnte nicht gestartet werden. Kehre zurück und füge stattdessen die Helper-Pairing-Einladung ein."; +"The credential changed. The existing namespace remains unavailable until its next immutable authorization epoch is explicitly completed." = "Das Credential wurde geändert. Der bestehende Namespace bleibt nicht verfügbar, bis seine nächste unveränderliche Autorisierungsepoche explizit abgeschlossen ist."; +"The current app key authorizes the next key. The old key is not retired until the helper acknowledgement and a capability query under the new key both succeed." = "Der aktuelle App-Schlüssel autorisiert den nächsten Schlüssel. Der alte Schlüssel wird erst ausgemustert, wenn sowohl die Helper-Bestätigung als auch eine Fähigkeitsabfrage mit dem neuen Schlüssel erfolgreich sind."; +"The helper must first be configured by its operator for a private local, LAN, or VPN endpoint and an exact existing folder. Public defaults and automatic discovery are not supported." = "Der Helper muss zuerst durch seinen Operator für einen privaten lokalen, LAN- oder VPN-Endpunkt und einen exakten vorhandenen Ordner konfiguriert werden. Öffentliche Standardwerte und automatische Discovery werden nicht unterstützt."; +"The helper operator must explicitly create the exact “VaultSync Diagnostics” namespace for this folder. VaultSync never creates or adopts it automatically." = "Der Helper-Operator muss den exakten Namespace „VaultSync Diagnostics“ ausdrücklich für diesen Ordner erstellen. VaultSync erstellt oder übernimmt ihn nie automatisch."; +"The selected folder was renamed or removed. Restore it in Syncthing before retrying." = "Der ausgewählte Ordner wurde umbenannt oder entfernt. Stelle ihn in Syncthing wieder her, bevor du es erneut versuchst."; +"The helper rate limit was reached. Wait before retrying the exact step." = "Das Helper-Ratenlimit wurde erreicht. Warte, bevor du den exakten Schritt wiederholst."; +"The signed step expired. Start again with a fresh operator action." = "Der signierte Schritt ist abgelaufen. Beginne mit einer neuen Operator-Aktion erneut."; +"This is scoped to the selected app, homeserver, and folder. Any authorized namespace will require a new immutable authorization epoch." = "Dies ist auf die ausgewählte App, den Homeserver und den Ordner begrenzt. Jeder autorisierte Namespace benötigt eine neue unveränderliche Autorisierungsepoche."; +"This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint." = "Dies entfernt nur die lokalen Diagnose-Credentials dieser App. Die alte Helper-Autorisierung wird nicht widerrufen. Paire mit einem neuen QR-Code und bitte danach den Helper-Operator, den verlorenen App-Fingerabdruck zu widerrufen."; +"This target is unsupported for controlled diagnostics." = "Dieses Ziel wird für kontrollierte Diagnose nicht unterstützt."; +"Unavailable" = "Nicht verfügbar"; +"Unavailable folder" = "Nicht verfügbarer Ordner"; +"Unavailable homeserver" = "Nicht verfügbarer Homeserver"; +"Unlock this device to use protected diagnostics credentials." = "Entsperre dieses Gerät, um geschützte Diagnose-Credentials zu verwenden."; +"Unsupported" = "Nicht unterstützt"; +"Use Pasted Invitation" = "Eingefügte Einladung verwenden"; +"Validate and Confirm Helper Key" = "Helper-Schlüssel prüfen und bestätigen"; +"Validate and Confirm TLS Pin" = "TLS-Pin prüfen und bestätigen"; +"VaultSync needs camera access only to scan the helper pairing QR you chose. You can paste the invitation instead." = "VaultSync benötigt Kamerazugriff nur, um den von dir gewählten Helper-Pairing-QR-Code zu scannen. Du kannst die Einladung stattdessen einfügen."; +"Cancel Pending Pairing" = "Ausstehendes Pairing abbrechen"; +"Discard Expired Pairing" = "Abgelaufenes Pairing verwerfen"; +"Retry Exact Pairing Cancellation" = "Exakten Pairing-Abbruch wiederholen"; diff --git a/ios/VaultSync/en.lproj/InfoPlist.strings b/ios/VaultSync/en.lproj/InfoPlist.strings index a157ace..123755a 100644 --- a/ios/VaultSync/en.lproj/InfoPlist.strings +++ b/ios/VaultSync/en.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; -"NSCameraUsageDescription" = "VaultSync uses the camera to scan Syncthing device QR codes for easy setup."; +"NSCameraUsageDescription" = "VaultSync uses the camera to scan setup and pairing QR codes you explicitly choose."; "NSLocalNetworkUsageDescription" = "VaultSync connects directly to your other devices on the same network so your vaults sync instantly, without a detour through the internet."; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 726ec85..0e74eec 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -810,3 +810,107 @@ /* Dormant M4 consent copy; no product flow references this key. */ "Enable VaultSync Diagnostics for homeserver “%@” and vault “%@”? After separate confirmation by the local operator, this creates the visible app-owned folder “VaultSync Diagnostics”. It contains only opaque random protocol data, public-key identifiers, signatures, hashes, and expiry metadata—never note content or user-derived filenames. The folder, temporary files, and deletion tombstones synchronize and can be visible in Obsidian, Files, and on every configured peer. Backups, Syncthing versioning, remote history, and tombstones may retain artifacts after live cleanup. Disabling stops new operations but does not automatically delete the root, credentials, peer copies, backups, versions, or tombstones." = "Enable VaultSync Diagnostics for homeserver “%@” and vault “%@”? After separate confirmation by the local operator, this creates the visible app-owned folder “VaultSync Diagnostics”. It contains only opaque random protocol data, public-key identifiers, signatures, hashes, and expiry metadata—never note content or user-derived filenames. The folder, temporary files, and deletion tombstones synchronize and can be visible in Obsidian, Files, and on every configured peer. Backups, Syncthing versioning, remote history, and tombstones may retain artifacts after live cleanup. Disabling stops new operations but does not automatically delete the root, credentials, peer copies, backups, versions, or tombstones."; +/* Controlled diagnostics */ +"A certificate renewal with the same pinned key needs no change. A new TLS key requires this signed transition." = "A certificate renewal with the same pinned key needs no change. A new TLS key requires this signed transition."; +"A conflicting immutable state was found. VaultSync did not adopt or overwrite it." = "A conflicting immutable state was found. VaultSync did not adopt or overwrite it."; +"Add the existing homeserver device and folder in Syncthing first." = "Add the existing homeserver device and folder in Syncthing first."; +"App key" = "App key"; +"App-key rotations reuse one staged installation key across folder authorizations. Each folder advances separately, and another key generation stays blocked until all non-revoked authorizations catch up." = "App-key rotations reuse one staged installation key across folder authorizations. Each folder advances separately, and another key generation stays blocked until all non-revoked authorizations catch up."; +"Authorization pending" = "Authorization pending"; +"Authorize Next Namespace Epoch" = "Authorize Next Namespace Epoch"; +"Authorize diagnostics pairing?" = "Authorize diagnostics pairing?"; +"Available" = "Available"; +"Capability" = "Capability"; +"Capability unavailable. Nothing was created or transferred." = "Capability unavailable. Nothing was created or transferred."; +"Check Authenticated Capability" = "Check Authenticated Capability"; +"Check Explicit Operator Step" = "Check Explicit Operator Step"; +"Checking…" = "Checking…"; +"Cloud Relay is not used for pairing, capability, namespace, or evidence." = "Cloud Relay is not used for pairing, capability, namespace, or evidence."; +"Compare this fingerprint with the helper operator:" = "Compare this fingerprint with the helper operator:"; +"Compatibility" = "Compatibility"; +"Confirmation required" = "Confirmation required"; +"Continue" = "Continue"; +"Continue Credential Rotation" = "Continue Credential Rotation"; +"Abort Pending Credential Rotation" = "Abort Pending Credential Rotation"; +"Discard Expired Credential Rotation" = "Discard Expired Credential Rotation"; +"Continue Verified Pairing" = "Continue Verified Pairing"; +"Controlled Diagnostics" = "Controlled Diagnostics"; +"Credential Maintenance" = "Credential Maintenance"; +"Explicit local or VPN pairing only" = "Explicit local or VPN pairing only"; +"Fingerprint Matches — Activate" = "Fingerprint Matches — Activate"; +"Folder" = "Folder"; +"Helper key" = "Helper key"; +"Helper new-key proof" = "Helper new-key proof"; +"Helper rotation proposal" = "Helper rotation proposal"; +"Homeserver" = "Homeserver"; +"If this app key is lost, pair the replacement as a new installation and revoke the old fingerprint locally on the helper. There is no cloud escrow or trust recovery." = "If this app key is lost, pair the replacement as a new installation and revoke the old fingerprint locally on the helper. There is no cloud escrow or trust recovery."; +"Namespace authorized" = "Namespace authorized"; +"Namespace authorized — no transfer artifact created" = "Namespace authorized — no transfer artifact created"; +"New explicit pairing" = "New explicit pairing"; +"New helper with an old app: diagnostics stays dormant." = "New helper with an old app: diagnostics stays dormant."; +"No diagnostics helper is paired." = "No diagnostics helper is paired."; +"No discovery, trust adoption, Relay tunnel, or automatic namespace" = "No discovery, trust adoption, Relay tunnel, or automatic namespace"; +"Old helper: capability unavailable; no pairing or namespace mutation." = "Old helper: capability unavailable; no pairing or namespace mutation."; +"Operator action required" = "Operator action required"; +"Optional, explicit helper pairing and namespace authorization. Nothing is created on upgrade or without your action." = "Optional, explicit helper pairing and namespace authorization. Nothing is created on upgrade or without your action."; +"Paired" = "Paired"; +"Paired targets" = "Paired targets"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. A later diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "Pairing and capability checks create no upload, download, or roundtrip evidence. A later diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones."; +"Pairing authorizes only controlled diagnostics for the selected server and folder. It does not create a namespace, transfer files, change Syncthing trust, or use Cloud Relay. Namespace setup remains a separate explicit app and operator action." = "Pairing authorizes only controlled diagnostics for the selected server and folder. It does not create a namespace, transfer files, change Syncthing trust, or use Cloud Relay. Namespace setup remains a separate explicit app and operator action."; +"Pairing fingerprint %@" = "Pairing fingerprint %@"; +"Pairing pending" = "Pairing pending"; +"Paste Invitation Instead" = "Paste Invitation Instead"; +"Paste pairing invitation" = "Paste pairing invitation"; +"Paste the two values from the explicit local helper-admin rotation. Both old- and new-key signatures must validate." = "Paste the two values from the explicit local helper-admin rotation. Both old- and new-key signatures must validate."; +"Protected app storage and the diagnostics Keychain no longer match. VaultSync will not reuse the surviving key automatically." = "Protected app storage and the diagnostics Keychain no longer match. VaultSync will not reuse the surviving key automatically."; +"Re-pair required" = "Re-pair required"; +"Re-pair required. Surviving credentials are never trusted automatically." = "Re-pair required. Surviving credentials are never trusted automatically."; +"Reauthorization required" = "Reauthorization required"; +"Recovery" = "Recovery"; +"Request Diagnostics Namespace" = "Request Diagnostics Namespace"; +"Reset Local Credentials" = "Reset Local Credentials"; +"Reset and Re-pair" = "Reset and Re-pair"; +"Reset local diagnostics credentials?" = "Reset local diagnostics credentials?"; +"Retry Exact Pairing Step" = "Retry Exact Pairing Step"; +"Retry Exact Revocation" = "Retry Exact Revocation"; +"Revocation and recovery" = "Revocation and recovery"; +"Revocation immediately prevents new controlled diagnostics operations for this exact authorization. It does not delete Syncthing data, namespace history, backups, versions, conflicts, or tombstones." = "Revocation immediately prevents new controlled diagnostics operations for this exact authorization. It does not delete Syncthing data, namespace history, backups, versions, conflicts, or tombstones."; +"Revocation pending" = "Revocation pending"; +"Revoke" = "Revoke"; +"Revoke This App Authorization" = "Revoke This App Authorization"; +"Revoke this app authorization?" = "Revoke this app authorization?"; +"Revoked" = "Revoked"; +"Rotate" = "Rotate"; +"Rotate App Signing Key" = "Rotate App Signing Key"; +"Rotate the app signing key?" = "Rotate the app signing key?"; +"Rotation pending" = "Rotation pending"; +"Rotation, Revocation & Recovery" = "Rotation, Revocation & Recovery"; +"Scan Helper Pairing QR" = "Scan Helper Pairing QR"; +"Security boundary" = "Security boundary"; +"TLS 1.3 with an exact QR-pinned key" = "TLS 1.3 with an exact QR-pinned key"; +"TLS pin" = "TLS pin"; +"TLS pin rotation proposal" = "TLS pin rotation proposal"; +"The authenticated protocol response was invalid." = "The authenticated protocol response was invalid."; +"The camera could not be started. Return and paste the helper pairing invitation instead." = "The camera could not be started. Return and paste the helper pairing invitation instead."; +"The credential changed. The existing namespace remains unavailable until its next immutable authorization epoch is explicitly completed." = "The credential changed. The existing namespace remains unavailable until its next immutable authorization epoch is explicitly completed."; +"The current app key authorizes the next key. The old key is not retired until the helper acknowledgement and a capability query under the new key both succeed." = "The current app key authorizes the next key. The old key is not retired until the helper acknowledgement and a capability query under the new key both succeed."; +"The helper must first be configured by its operator for a private local, LAN, or VPN endpoint and an exact existing folder. Public defaults and automatic discovery are not supported." = "The helper must first be configured by its operator for a private local, LAN, or VPN endpoint and an exact existing folder. Public defaults and automatic discovery are not supported."; +"The helper operator must explicitly create the exact “VaultSync Diagnostics” namespace for this folder. VaultSync never creates or adopts it automatically." = "The helper operator must explicitly create the exact “VaultSync Diagnostics” namespace for this folder. VaultSync never creates or adopts it automatically."; +"The selected folder was renamed or removed. Restore it in Syncthing before retrying." = "The selected folder was renamed or removed. Restore it in Syncthing before retrying."; +"The helper rate limit was reached. Wait before retrying the exact step." = "The helper rate limit was reached. Wait before retrying the exact step."; +"The signed step expired. Start again with a fresh operator action." = "The signed step expired. Start again with a fresh operator action."; +"This is scoped to the selected app, homeserver, and folder. Any authorized namespace will require a new immutable authorization epoch." = "This is scoped to the selected app, homeserver, and folder. Any authorized namespace will require a new immutable authorization epoch."; +"This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint." = "This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint."; +"This target is unsupported for controlled diagnostics." = "This target is unsupported for controlled diagnostics."; +"Unavailable" = "Unavailable"; +"Unavailable folder" = "Unavailable folder"; +"Unavailable homeserver" = "Unavailable homeserver"; +"Unlock this device to use protected diagnostics credentials." = "Unlock this device to use protected diagnostics credentials."; +"Unsupported" = "Unsupported"; +"Use Pasted Invitation" = "Use Pasted Invitation"; +"Validate and Confirm Helper Key" = "Validate and Confirm Helper Key"; +"Validate and Confirm TLS Pin" = "Validate and Confirm TLS Pin"; +"VaultSync needs camera access only to scan the helper pairing QR you chose. You can paste the invitation instead." = "VaultSync needs camera access only to scan the helper pairing QR you chose. You can paste the invitation instead."; +"Cancel Pending Pairing" = "Cancel Pending Pairing"; +"Discard Expired Pairing" = "Discard Expired Pairing"; +"Retry Exact Pairing Cancellation" = "Retry Exact Pairing Cancellation"; diff --git a/ios/VaultSync/es.lproj/InfoPlist.strings b/ios/VaultSync/es.lproj/InfoPlist.strings index 5655f7b..751beb5 100644 --- a/ios/VaultSync/es.lproj/InfoPlist.strings +++ b/ios/VaultSync/es.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; -"NSCameraUsageDescription" = "VaultSync usa la cámara para escanear códigos QR de dispositivos de Syncthing y facilitar la configuración."; +"NSCameraUsageDescription" = "VaultSync usa la cámara para escanear los códigos QR de configuración y emparejamiento que elijas expresamente."; "NSLocalNetworkUsageDescription" = "VaultSync se conecta directamente a tus otros dispositivos en la misma red para que tus bóvedas se sincronicen al instante, sin pasar por internet."; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 87c5d3b..da13ca9 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -810,3 +810,107 @@ /* Texto de consentimiento M4 inactivo; ningún flujo del producto usa esta clave. */ "Enable VaultSync Diagnostics for homeserver “%@” and vault “%@”? After separate confirmation by the local operator, this creates the visible app-owned folder “VaultSync Diagnostics”. It contains only opaque random protocol data, public-key identifiers, signatures, hashes, and expiry metadata—never note content or user-derived filenames. The folder, temporary files, and deletion tombstones synchronize and can be visible in Obsidian, Files, and on every configured peer. Backups, Syncthing versioning, remote history, and tombstones may retain artifacts after live cleanup. Disabling stops new operations but does not automatically delete the root, credentials, peer copies, backups, versions, or tombstones." = "¿Activar VaultSync Diagnostics para el servidor doméstico “%@” y el Vault “%@”? Tras una confirmación separada por parte del operador local, se crea la carpeta visible propiedad de la aplicación “VaultSync Diagnostics”. Solo contiene datos aleatorios opacos del protocolo, identificadores de claves públicas, firmas, hashes y metadatos de caducidad; nunca contenido de notas ni nombres de archivo derivados del usuario. La carpeta, los archivos temporales y los registros de eliminación se sincronizan y pueden verse en Obsidian, Archivos y en cada par configurado. Las copias de seguridad, el versionado de Syncthing, el historial remoto y los registros de eliminación pueden conservar artefactos tras la limpieza activa. La desactivación detiene las operaciones nuevas, pero no elimina automáticamente la raíz, las credenciales, las copias de los pares, las copias de seguridad, las versiones ni los registros de eliminación."; +/* Diagnóstico controlado */ +"A certificate renewal with the same pinned key needs no change. A new TLS key requires this signed transition." = "Renovar el certificado con la misma clave fijada no requiere cambios. Una clave TLS nueva requiere esta transición firmada."; +"A conflicting immutable state was found. VaultSync did not adopt or overwrite it." = "Se encontró un estado inmutable en conflicto. VaultSync no lo adoptó ni lo sobrescribió."; +"Add the existing homeserver device and folder in Syncthing first." = "Añade primero en Syncthing el dispositivo del servidor doméstico y la carpeta existentes."; +"App key" = "Clave de la app"; +"App-key rotations reuse one staged installation key across folder authorizations. Each folder advances separately, and another key generation stays blocked until all non-revoked authorizations catch up." = "Las rotaciones de la clave de la app reutilizan una única clave de instalación preparada en todas las autorizaciones de carpetas. Cada carpeta avanza por separado y se bloquea otra generación hasta que todas las autorizaciones no revocadas se pongan al día."; +"Authorization pending" = "Autorización pendiente"; +"Authorize Next Namespace Epoch" = "Autorizar la siguiente época del espacio de nombres"; +"Authorize diagnostics pairing?" = "¿Autorizar el emparejamiento de diagnóstico?"; +"Available" = "Disponible"; +"Capability" = "Capacidad"; +"Capability unavailable. Nothing was created or transferred." = "Capacidad no disponible. No se creó ni transfirió nada."; +"Check Authenticated Capability" = "Comprobar capacidad autenticada"; +"Check Explicit Operator Step" = "Comprobar paso explícito del operador"; +"Checking…" = "Comprobando…"; +"Cloud Relay is not used for pairing, capability, namespace, or evidence." = "Cloud Relay no se usa para el emparejamiento, la capacidad, el espacio de nombres ni las pruebas."; +"Compare this fingerprint with the helper operator:" = "Compara esta huella con la del operador del helper:"; +"Compatibility" = "Compatibilidad"; +"Confirmation required" = "Confirmación necesaria"; +"Continue" = "Continuar"; +"Continue Credential Rotation" = "Continuar la rotación de credenciales"; +"Abort Pending Credential Rotation" = "Cancelar la rotación de credenciales pendiente"; +"Discard Expired Credential Rotation" = "Descartar la rotación de credenciales caducada"; +"Continue Verified Pairing" = "Continuar el emparejamiento verificado"; +"Controlled Diagnostics" = "Diagnóstico controlado"; +"Credential Maintenance" = "Mantenimiento de credenciales"; +"Explicit local or VPN pairing only" = "Solo emparejamiento local o por VPN explícito"; +"Fingerprint Matches — Activate" = "La huella coincide — Activar"; +"Folder" = "Carpeta"; +"Helper key" = "Clave del helper"; +"Helper new-key proof" = "Prueba de la clave nueva del helper"; +"Helper rotation proposal" = "Propuesta de rotación del helper"; +"Homeserver" = "Servidor doméstico"; +"If this app key is lost, pair the replacement as a new installation and revoke the old fingerprint locally on the helper. There is no cloud escrow or trust recovery." = "Si se pierde esta clave de la app, empareja el reemplazo como instalación nueva y revoca localmente la huella anterior en el helper. No hay custodia en la nube ni recuperación de confianza."; +"Namespace authorized" = "Espacio de nombres autorizado"; +"Namespace authorized — no transfer artifact created" = "Espacio de nombres autorizado — no se creó ningún artefacto de transferencia"; +"New explicit pairing" = "Nuevo emparejamiento explícito"; +"New helper with an old app: diagnostics stays dormant." = "Helper nuevo con app antigua: el diagnóstico permanece inactivo."; +"No diagnostics helper is paired." = "No hay ningún helper de diagnóstico emparejado."; +"No discovery, trust adoption, Relay tunnel, or automatic namespace" = "Sin descubrimiento, adopción de confianza, túnel Relay ni espacio de nombres automático"; +"Old helper: capability unavailable; no pairing or namespace mutation." = "Helper antiguo: capacidad no disponible; sin cambios de emparejamiento ni del espacio de nombres."; +"Operator action required" = "Se requiere acción del operador"; +"Optional, explicit helper pairing and namespace authorization. Nothing is created on upgrade or without your action." = "Emparejamiento explícito opcional del helper y autorización del espacio de nombres. No se crea nada al actualizar ni sin tu acción."; +"Paired" = "Emparejado"; +"Paired targets" = "Destinos emparejados"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. A later diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "El emparejamiento y las comprobaciones de capacidad no crean pruebas de subida, descarga ni ida y vuelta. Un espacio de nombres de diagnóstico posterior será visible para los pares sincronizados y puede permanecer en copias de seguridad, versiones, copias en conflicto y registros de eliminación."; +"Pairing authorizes only controlled diagnostics for the selected server and folder. It does not create a namespace, transfer files, change Syncthing trust, or use Cloud Relay. Namespace setup remains a separate explicit app and operator action." = "El emparejamiento solo autoriza el diagnóstico controlado para el servidor y la carpeta seleccionados. No crea un espacio de nombres, transfiere archivos, cambia la confianza de Syncthing ni usa Cloud Relay. Configurar el espacio de nombres sigue siendo una acción explícita y separada de la app y del operador."; +"Pairing fingerprint %@" = "Huella de emparejamiento %@"; +"Pairing pending" = "Emparejamiento pendiente"; +"Paste Invitation Instead" = "Pegar la invitación"; +"Paste pairing invitation" = "Pega la invitación de emparejamiento"; +"Paste the two values from the explicit local helper-admin rotation. Both old- and new-key signatures must validate." = "Pega los dos valores de la rotación local explícita del administrador del helper. Deben validarse las firmas de las claves antigua y nueva."; +"Protected app storage and the diagnostics Keychain no longer match. VaultSync will not reuse the surviving key automatically." = "El almacenamiento protegido de la app y el llavero de diagnóstico ya no coinciden. VaultSync no reutilizará automáticamente la clave superviviente."; +"Re-pair required" = "Es necesario volver a emparejar"; +"Re-pair required. Surviving credentials are never trusted automatically." = "Es necesario volver a emparejar. Nunca se confía automáticamente en credenciales supervivientes."; +"Reauthorization required" = "Se requiere nueva autorización"; +"Recovery" = "Recuperación"; +"Request Diagnostics Namespace" = "Solicitar espacio de nombres de diagnóstico"; +"Reset Local Credentials" = "Restablecer credenciales locales"; +"Reset and Re-pair" = "Restablecer y volver a emparejar"; +"Reset local diagnostics credentials?" = "¿Restablecer las credenciales locales de diagnóstico?"; +"Retry Exact Pairing Step" = "Reintentar el paso exacto de emparejamiento"; +"Retry Exact Revocation" = "Reintentar la revocación exacta"; +"Revocation and recovery" = "Revocación y recuperación"; +"Revocation immediately prevents new controlled diagnostics operations for this exact authorization. It does not delete Syncthing data, namespace history, backups, versions, conflicts, or tombstones." = "La revocación impide inmediatamente nuevas operaciones de diagnóstico controlado para esta autorización exacta. No elimina datos de Syncthing, historial del espacio de nombres, copias de seguridad, versiones, conflictos ni registros de eliminación."; +"Revocation pending" = "Revocación pendiente"; +"Revoke" = "Revocar"; +"Revoke This App Authorization" = "Revocar esta autorización de la app"; +"Revoke this app authorization?" = "¿Revocar esta autorización de la app?"; +"Revoked" = "Revocada"; +"Rotate" = "Rotar"; +"Rotate App Signing Key" = "Rotar la clave de firma de la app"; +"Rotate the app signing key?" = "¿Rotar la clave de firma de la app?"; +"Rotation pending" = "Rotación pendiente"; +"Rotation, Revocation & Recovery" = "Rotación, revocación y recuperación"; +"Scan Helper Pairing QR" = "Escanear QR de emparejamiento del helper"; +"Security boundary" = "Límite de seguridad"; +"TLS 1.3 with an exact QR-pinned key" = "TLS 1.3 con la clave exacta fijada por QR"; +"TLS pin" = "Pin TLS"; +"TLS pin rotation proposal" = "Propuesta de rotación del pin TLS"; +"The authenticated protocol response was invalid." = "La respuesta autenticada del protocolo no era válida."; +"The camera could not be started. Return and paste the helper pairing invitation instead." = "No se pudo iniciar la cámara. Vuelve y pega la invitación de emparejamiento del helper."; +"The credential changed. The existing namespace remains unavailable until its next immutable authorization epoch is explicitly completed." = "La credencial cambió. El espacio de nombres existente seguirá sin estar disponible hasta completar explícitamente su siguiente época inmutable de autorización."; +"The current app key authorizes the next key. The old key is not retired until the helper acknowledgement and a capability query under the new key both succeed." = "La clave actual de la app autoriza la siguiente. La clave antigua no se retira hasta que tengan éxito la confirmación del helper y una consulta de capacidad con la clave nueva."; +"The helper must first be configured by its operator for a private local, LAN, or VPN endpoint and an exact existing folder. Public defaults and automatic discovery are not supported." = "El operador debe configurar primero el helper para un endpoint privado local, de LAN o VPN y una carpeta existente exacta. No se admiten valores públicos predeterminados ni descubrimiento automático."; +"The helper operator must explicitly create the exact “VaultSync Diagnostics” namespace for this folder. VaultSync never creates or adopts it automatically." = "El operador del helper debe crear explícitamente el espacio de nombres exacto «VaultSync Diagnostics» para esta carpeta. VaultSync nunca lo crea ni adopta automáticamente."; +"The selected folder was renamed or removed. Restore it in Syncthing before retrying." = "La carpeta seleccionada se renombró o eliminó. Restáurala en Syncthing antes de volver a intentarlo."; +"The helper rate limit was reached. Wait before retrying the exact step." = "Se alcanzó el límite de solicitudes del helper. Espera antes de reintentar el paso exacto."; +"The signed step expired. Start again with a fresh operator action." = "El paso firmado caducó. Empieza de nuevo con una acción reciente del operador."; +"This is scoped to the selected app, homeserver, and folder. Any authorized namespace will require a new immutable authorization epoch." = "Esto se limita a la app, el servidor doméstico y la carpeta seleccionados. Todo espacio de nombres autorizado necesitará una nueva época inmutable de autorización."; +"This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint." = "Esto solo elimina las credenciales locales de diagnóstico de esta app. No revoca la autorización anterior del helper. Vuelve a emparejar con un QR nuevo y pide al operador del helper que revoque la huella perdida de la app."; +"This target is unsupported for controlled diagnostics." = "Este destino no es compatible con el diagnóstico controlado."; +"Unavailable" = "No disponible"; +"Unavailable folder" = "Carpeta no disponible"; +"Unavailable homeserver" = "Servidor doméstico no disponible"; +"Unlock this device to use protected diagnostics credentials." = "Desbloquea este dispositivo para usar las credenciales protegidas de diagnóstico."; +"Unsupported" = "No compatible"; +"Use Pasted Invitation" = "Usar invitación pegada"; +"Validate and Confirm Helper Key" = "Validar y confirmar clave del helper"; +"Validate and Confirm TLS Pin" = "Validar y confirmar pin TLS"; +"VaultSync needs camera access only to scan the helper pairing QR you chose. You can paste the invitation instead." = "VaultSync solo necesita acceso a la cámara para escanear el QR de emparejamiento del helper que elijas. También puedes pegar la invitación."; +"Cancel Pending Pairing" = "Cancelar emparejamiento pendiente"; +"Discard Expired Pairing" = "Descartar emparejamiento caducado"; +"Retry Exact Pairing Cancellation" = "Reintentar cancelación exacta del emparejamiento"; diff --git a/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings b/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings index 000ef75..2b22814 100644 --- a/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings +++ b/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings @@ -1,3 +1,3 @@ "CFBundleDisplayName" = "VaultSync"; -"NSCameraUsageDescription" = "VaultSync 需要使用相机扫描 Syncthing 设备二维码,以便轻松完成设置。"; +"NSCameraUsageDescription" = "VaultSync 仅使用相机扫描你主动选择的 Syncthing 设备 ID 二维码和受控诊断配对二维码。"; "NSLocalNetworkUsageDescription" = "VaultSync 直接连接同一网络中的其他设备,让你的仓库即时同步,无需绕道互联网。"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 3393b66..1227805 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -810,3 +810,107 @@ /* 未启用的 M4 同意文案;产品流程未引用此键。 */ "Enable VaultSync Diagnostics for homeserver “%@” and vault “%@”? After separate confirmation by the local operator, this creates the visible app-owned folder “VaultSync Diagnostics”. It contains only opaque random protocol data, public-key identifiers, signatures, hashes, and expiry metadata—never note content or user-derived filenames. The folder, temporary files, and deletion tombstones synchronize and can be visible in Obsidian, Files, and on every configured peer. Backups, Syncthing versioning, remote history, and tombstones may retain artifacts after live cleanup. Disabling stops new operations but does not automatically delete the root, credentials, peer copies, backups, versions, or tombstones." = "要为家庭服务器“%@”和 Vault“%@”启用 VaultSync Diagnostics 吗?经本地操作员另行确认后,系统会创建应用专属的可见文件夹“VaultSync Diagnostics”。其中只包含不透明的随机协议数据、公钥标识符、签名、哈希和过期元数据,绝不包含笔记内容或由用户数据派生的文件名。该文件夹、临时文件和删除记录会同步,并可能显示在 Obsidian、“文件”以及每个已配置的对等设备上。备份、Syncthing 版本控制、远程历史记录和删除记录可能在实时清理后继续保留这些数据。停用功能会停止新操作,但不会自动删除根文件夹、凭据、对等设备副本、备份、版本或删除记录。"; +/* 受控诊断 */ +"A certificate renewal with the same pinned key needs no change. A new TLS key requires this signed transition." = "使用同一固定密钥续订证书无需更改。新的 TLS 密钥必须经过此签名转换。"; +"A conflicting immutable state was found. VaultSync did not adopt or overwrite it." = "发现冲突的不可变状态。VaultSync 未采用或覆盖它。"; +"Add the existing homeserver device and folder in Syncthing first." = "请先在 Syncthing 中添加现有家庭服务器设备和文件夹。"; +"App key" = "App 密钥"; +"App-key rotations reuse one staged installation key across folder authorizations. Each folder advances separately, and another key generation stays blocked until all non-revoked authorizations catch up." = "App 密钥轮换会在所有文件夹授权中复用同一个已暂存安装密钥。每个文件夹单独推进;在所有未撤销授权完成跟进之前,不会生成下一代密钥。"; +"Authorization pending" = "授权待完成"; +"Authorize Next Namespace Epoch" = "授权下一个命名空间时期"; +"Authorize diagnostics pairing?" = "授权诊断配对?"; +"Available" = "可用"; +"Capability" = "能力"; +"Capability unavailable. Nothing was created or transferred." = "能力不可用。未创建或传输任何内容。"; +"Check Authenticated Capability" = "检查已认证能力"; +"Check Explicit Operator Step" = "检查明确的操作员步骤"; +"Checking…" = "正在检查…"; +"Cloud Relay is not used for pairing, capability, namespace, or evidence." = "配对、能力、命名空间和证据均不使用 Cloud Relay。"; +"Compare this fingerprint with the helper operator:" = "请与 Helper 操作员核对以下指纹:"; +"Compatibility" = "兼容性"; +"Confirmation required" = "需要确认"; +"Continue" = "继续"; +"Continue Credential Rotation" = "继续凭据轮换"; +"Abort Pending Credential Rotation" = "中止待处理的凭据轮换"; +"Discard Expired Credential Rotation" = "丢弃已过期的凭据轮换"; +"Continue Verified Pairing" = "继续已验证的配对"; +"Controlled Diagnostics" = "受控诊断"; +"Credential Maintenance" = "凭据维护"; +"Explicit local or VPN pairing only" = "仅限明确的本地或 VPN 配对"; +"Fingerprint Matches — Activate" = "指纹一致 — 激活"; +"Folder" = "文件夹"; +"Helper key" = "Helper 密钥"; +"Helper new-key proof" = "Helper 新密钥证明"; +"Helper rotation proposal" = "Helper 轮换提案"; +"Homeserver" = "家庭服务器"; +"If this app key is lost, pair the replacement as a new installation and revoke the old fingerprint locally on the helper. There is no cloud escrow or trust recovery." = "如果此 App 密钥丢失,请将替代设备作为新安装进行配对,并在 Helper 本地撤销旧指纹。没有云托管或信任恢复。"; +"Namespace authorized" = "命名空间已授权"; +"Namespace authorized — no transfer artifact created" = "命名空间已授权 — 未创建传输工件"; +"New explicit pairing" = "新的明确配对"; +"New helper with an old app: diagnostics stays dormant." = "新 Helper 搭配旧 App:诊断保持休眠。"; +"No diagnostics helper is paired." = "尚未配对诊断 Helper。"; +"No discovery, trust adoption, Relay tunnel, or automatic namespace" = "无自动发现、信任接管、Relay 隧道或自动命名空间"; +"Old helper: capability unavailable; no pairing or namespace mutation." = "旧 Helper:能力不可用;不会更改配对或命名空间。"; +"Operator action required" = "需要操作员操作"; +"Optional, explicit helper pairing and namespace authorization. Nothing is created on upgrade or without your action." = "可选的明确 Helper 配对和命名空间授权。升级时或未经你的操作不会创建任何内容。"; +"Paired" = "已配对"; +"Paired targets" = "已配对目标"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. A later diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "配对和能力检查不会创建上传、下载或往返证据。之后的诊断命名空间对同步对等设备可见,并可能保留在备份、版本、冲突副本和删除记录中。"; +"Pairing authorizes only controlled diagnostics for the selected server and folder. It does not create a namespace, transfer files, change Syncthing trust, or use Cloud Relay. Namespace setup remains a separate explicit app and operator action." = "配对只为所选服务器和文件夹授权受控诊断。它不会创建命名空间、传输文件、更改 Syncthing 信任或使用 Cloud Relay。设置命名空间仍需要 App 用户和操作员分别明确操作。"; +"Pairing fingerprint %@" = "配对指纹 %@"; +"Pairing pending" = "配对待完成"; +"Paste Invitation Instead" = "改为粘贴邀请"; +"Paste pairing invitation" = "粘贴配对邀请"; +"Paste the two values from the explicit local helper-admin rotation. Both old- and new-key signatures must validate." = "粘贴由本地 Helper 管理员明确轮换产生的两个值。旧密钥和新密钥签名都必须验证通过。"; +"Protected app storage and the diagnostics Keychain no longer match. VaultSync will not reuse the surviving key automatically." = "受保护的 App 存储与诊断钥匙串不再匹配。VaultSync 不会自动复用残留密钥。"; +"Re-pair required" = "需要重新配对"; +"Re-pair required. Surviving credentials are never trusted automatically." = "需要重新配对。绝不会自动信任残留凭据。"; +"Reauthorization required" = "需要重新授权"; +"Recovery" = "恢复"; +"Request Diagnostics Namespace" = "请求诊断命名空间"; +"Reset Local Credentials" = "重置本地凭据"; +"Reset and Re-pair" = "重置并重新配对"; +"Reset local diagnostics credentials?" = "重置本地诊断凭据?"; +"Retry Exact Pairing Step" = "重试完全相同的配对步骤"; +"Retry Exact Revocation" = "重试完全相同的撤销"; +"Revocation and recovery" = "撤销与恢复"; +"Revocation immediately prevents new controlled diagnostics operations for this exact authorization. It does not delete Syncthing data, namespace history, backups, versions, conflicts, or tombstones." = "撤销会立即阻止此确切授权的新受控诊断操作。它不会删除 Syncthing 数据、命名空间历史、备份、版本、冲突或删除记录。"; +"Revocation pending" = "撤销待完成"; +"Revoke" = "撤销"; +"Revoke This App Authorization" = "撤销此 App 授权"; +"Revoke this app authorization?" = "撤销此 App 授权?"; +"Revoked" = "已撤销"; +"Rotate" = "轮换"; +"Rotate App Signing Key" = "轮换 App 签名密钥"; +"Rotate the app signing key?" = "轮换 App 签名密钥?"; +"Rotation pending" = "轮换待完成"; +"Rotation, Revocation & Recovery" = "轮换、撤销与恢复"; +"Scan Helper Pairing QR" = "扫描 Helper 配对二维码"; +"Security boundary" = "安全边界"; +"TLS 1.3 with an exact QR-pinned key" = "TLS 1.3 与二维码精确固定密钥"; +"TLS pin" = "TLS 固定值"; +"TLS pin rotation proposal" = "TLS 固定值轮换提案"; +"The authenticated protocol response was invalid." = "已认证的协议响应无效。"; +"The camera could not be started. Return and paste the helper pairing invitation instead." = "无法启动相机。请返回并改为粘贴 Helper 配对邀请。"; +"The credential changed. The existing namespace remains unavailable until its next immutable authorization epoch is explicitly completed." = "凭据已更改。在明确完成下一个不可变授权时期之前,现有命名空间仍不可用。"; +"The current app key authorizes the next key. The old key is not retired until the helper acknowledgement and a capability query under the new key both succeed." = "当前 App 密钥授权下一个密钥。只有 Helper 确认以及使用新密钥的能力查询都成功后,旧密钥才会退役。"; +"The helper must first be configured by its operator for a private local, LAN, or VPN endpoint and an exact existing folder. Public defaults and automatic discovery are not supported." = "操作员必须先为 Helper 配置私有本地、局域网或 VPN 端点以及确切的现有文件夹。不支持公共默认值和自动发现。"; +"The helper operator must explicitly create the exact “VaultSync Diagnostics” namespace for this folder. VaultSync never creates or adopts it automatically." = "Helper 操作员必须为此文件夹明确创建名称完全为“VaultSync Diagnostics”的命名空间。VaultSync 绝不会自动创建或采用它。"; +"The selected folder was renamed or removed. Restore it in Syncthing before retrying." = "所选文件夹已被重命名或移除。请先在 Syncthing 中恢复它,然后重试。"; +"The helper rate limit was reached. Wait before retrying the exact step." = "已达到 Helper 速率限制。请稍后再重试完全相同的步骤。"; +"The signed step expired. Start again with a fresh operator action." = "已签名步骤已过期。请通过新的操作员操作重新开始。"; +"This is scoped to the selected app, homeserver, and folder. Any authorized namespace will require a new immutable authorization epoch." = "此操作仅限于所选 App、家庭服务器和文件夹。任何已授权命名空间都需要新的不可变授权时期。"; +"This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint." = "这只会删除此 App 的本地诊断凭据,不会撤销旧 Helper 授权。请使用新二维码重新配对,然后让 Helper 操作员撤销丢失的 App 指纹。"; +"This target is unsupported for controlled diagnostics." = "此目标不支持受控诊断。"; +"Unavailable" = "不可用"; +"Unavailable folder" = "文件夹不可用"; +"Unavailable homeserver" = "家庭服务器不可用"; +"Unlock this device to use protected diagnostics credentials." = "请解锁此设备以使用受保护的诊断凭据。"; +"Unsupported" = "不支持"; +"Use Pasted Invitation" = "使用已粘贴的邀请"; +"Validate and Confirm Helper Key" = "验证并确认 Helper 密钥"; +"Validate and Confirm TLS Pin" = "验证并确认 TLS 固定值"; +"VaultSync needs camera access only to scan the helper pairing QR you chose. You can paste the invitation instead." = "VaultSync 仅需使用相机扫描你选择的 Helper 配对二维码。你也可以改为粘贴邀请。"; +"Cancel Pending Pairing" = "取消待处理配对"; +"Discard Expired Pairing" = "丢弃已过期配对"; +"Retry Exact Pairing Cancellation" = "重试原配对取消请求"; diff --git a/ios/VaultSyncTests/DiagnosticsAppRuntimeM3Tests.swift b/ios/VaultSyncTests/DiagnosticsAppRuntimeM3Tests.swift new file mode 100644 index 0000000..faed5c7 --- /dev/null +++ b/ios/VaultSyncTests/DiagnosticsAppRuntimeM3Tests.swift @@ -0,0 +1,1917 @@ +import CryptoKit +import Foundation +import Security +import Testing +@testable import VaultSync + +@Suite("Controlled diagnostics app runtime (M3)", .serialized) +struct DiagnosticsAppRuntimeM3Tests { + @Test("Production pairing decoder accepts every Decision 022 golden and rejects tampering") + func productionPairingGoldens() throws { + let fixture = try loadDiagnosticsHexFixture(named: "diagnostics-pairing-m3") + let names = [ + "00_qr", "01_app_request", "02_helper_accept", "03_finalize", "04_finalize_ack", + "05_receipt", "06_ready_ack", "07_activate", "08_active_ack", "09_abort", "10_abort_ack", + "11_app_key_rotation_request", "12_app_key_rotation_new_proof", "13_app_key_rotation_accept", + "14_helper_key_rotation_propose", "15_helper_key_rotation_new_proof", "16_helper_key_rotation_confirm", + "17_tls_pin_rotation_propose", "18_tls_pin_rotation_confirm", "19_revocation_request", + "20_revocation_record", "21_lifecycle_finalize", "22_lifecycle_active_ack", + "23_lifecycle_abort", "24_lifecycle_abort_ack", + ] + #expect(fixture.keys.sorted() == names) + + for (rawType, name) in names.enumerated() { + let encoded = try Data(m1Hex: #require(fixture[name])) + let message: DiagnosticsPairingProtocol.Message + do { + message = try DiagnosticsPairingProtocol.decode(encoded) + } catch { + Issue.record("Production decoder rejected \(name): \(error)") + continue + } + #expect(message.type.rawValue == UInt64(rawType)) + #expect(message.canonical == encoded) + #expect(try DiagnosticsDeterministicCBOR.encode(message.value) == encoded) + + if rawType > 0 { + var tampered = encoded + tampered[tampered.index(before: tampered.endIndex)] ^= 0x01 + expectDiagnosticsError(.invalidMessage) { + _ = try DiagnosticsPairingProtocol.decode(tampered) + } + } + } + + let qrData = try Data(m1Hex: #require(fixture["00_qr"])) + let qrMessage = try DiagnosticsPairingProtocol.decode(qrData) + let issuedAt = try #require(qrMessage.value.unsigned(for: 15)) + let qr = DiagnosticsCrypto.base64URLEncode(qrData) + #expect( + try DiagnosticsPairingProtocol.decodeQR( + qr, + now: Date(timeIntervalSince1970: TimeInterval(issuedAt)) + ).canonical == qrData + ) + + for invalid in ["a201000101", "a202000100", "1817", "5f4100ff", "bf0100ff", "f4"] { + expectDiagnosticsError(.invalidMessage) { + _ = try DiagnosticsDeterministicCBOR.decode(Data(m1Hex: invalid)) + } + } + expectDiagnosticsError(.invalidMessage) { + _ = try DiagnosticsCrypto.base64URLDecode( + String(repeating: "A", count: DiagnosticsDeterministicCBOR.maximumMessageBytes * 2) + ) + } + } + + @Test("Extreme external epochs and timestamps fail closed without arithmetic traps") + func arithmeticBoundaries() throws { + let fixture = try loadDiagnosticsHexFixture(named: "diagnostics-pairing-m3") + let rotation = try DiagnosticsDeterministicCBOR.decode( + Data(m1Hex: #require(fixture["11_app_key_rotation_request"])) + ) + guard case .map(var fields) = rotation else { + Issue.record("Expected lifecycle map") + return + } + fields = fields.map { field in + switch field.label { + case 17, 18: + return DiagnosticsCBORField(label: field.label, value: .unsigned(UInt64.max)) + default: + return field + } + } + let extremeEpoch = try DiagnosticsDeterministicCBOR.encode(.map(fields)) + expectDiagnosticsError(.invalidMessage) { + _ = try DiagnosticsPairingProtocol.decode(extremeEpoch) + } + + let appKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(repeating: 0x31, count: 32) + ) + let record = makeRuntimeRecord( + appSeed: appKey.rawRepresentation, + helperPublic: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, + homeserver: Data(repeating: 0x11, count: 32), + folder: Data(repeating: 0x12, count: 32) + ) + expectDiagnosticsError(.invalidMessage) { + _ = try DiagnosticsCapabilityProtocol.makeQuery( + record: record, + appKey: appKey, + nonce: Data(repeating: 0x44, count: 32), + now: Date(timeIntervalSince1970: Double(UInt64.max)) + ) + } + } + + @Test("Production capability query is byte-exact and response validation is mutually authenticated") + func capabilityWireContract() throws { + let fixture = try M1ContractFixtureLoader.load() + let vector = fixture.vectors.contractQuery + let appKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(m1Hex: fixture.vectors.rfc8032.seedHex) + ) + let helperPublic = Data(repeating: vector.helperPublicKeyByte, count: 32) + let record = makeRuntimeRecord( + appSeed: appKey.rawRepresentation, + helperPublic: helperPublic, + homeserver: Data(repeating: vector.homeserverByte, count: 32), + folder: Data(repeating: vector.folderByte, count: 32) + ) + let query = try DiagnosticsCapabilityProtocol.makeQuery( + record: record, + appKey: appKey, + nonce: Data(repeating: vector.queryNonceByte, count: 32), + now: Date(timeIntervalSince1970: TimeInterval(vector.issuedAt)) + ) + let value = try DiagnosticsDeterministicCBOR.decode(query.message) + let body = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + #expect(body.m1Hex == vector.expectedCanonicalBodyHex) + #expect(query.digest.m1Hex == vector.expectedDigestHex) + + var signedInput = Data("eu.vaultsync.roundtrip/v1/capability-query\0".utf8) + signedInput.append(body) + let expectedSignature = try Data(m1Hex: vector.expectedSignatureHex) + #expect(appKey.publicKey.isValidSignature(expectedSignature, for: signedInput)) + #expect(appKey.publicKey.isValidSignature(try #require(value.bytes(for: 255, count: 64)), for: signedInput)) + + let helperKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x44, count: 32)) + let responseRecord = makeRuntimeRecord( + appSeed: appKey.rawRepresentation, + helperPublic: helperKey.publicKey.rawRepresentation, + homeserver: record.homeserverBinding, + folder: record.folderBinding + ) + let responseQuery = try DiagnosticsCapabilityProtocol.makeQuery( + record: responseRecord, + appKey: appKey, + nonce: Data(repeating: 0x45, count: 32), + now: Date(timeIntervalSince1970: 1_700_000_100) + ) + let response = try makeCapabilityResponse( + query: responseQuery, + record: responseRecord, + helperKey: helperKey, + flags: DiagnosticsCapabilityProtocol.requiredFlags, + issuedAt: 1_700_000_100 + ) + try DiagnosticsCapabilityProtocol.validateResponse( + response, + query: responseQuery, + record: responseRecord, + now: Date(timeIntervalSince1970: 1_700_000_100) + ) + + let incomplete = try makeCapabilityResponse( + query: responseQuery, + record: responseRecord, + helperKey: helperKey, + flags: DiagnosticsCapabilityProtocol.requiredFlags - 1, + issuedAt: 1_700_000_100 + ) + expectDiagnosticsError(.invalidMessage) { + try DiagnosticsCapabilityProtocol.validateResponse( + incomplete, + query: responseQuery, + record: responseRecord, + now: Date(timeIntervalSince1970: 1_700_000_100) + ) + } + } + + @Test("Production namespace records reproduce and validate the Decision 023 golden chain") + @MainActor + func namespaceWireContract() async throws { + let fixture = try loadDiagnosticsHexFixture(named: "diagnostics-namespace-m4") + let enablement = try Data(m1Hex: #require(fixture["01_enablement"])) + let rootData = try Data(m1Hex: #require(fixture["02_root_manifest"])) + let helperEpochData = try Data(m1Hex: #require(fixture["03_helper_epoch"])) + let initialAuthorization = try Data(m1Hex: #require(fixture["04_initial_authorization"])) + let authorizationEpoch = try Data(m1Hex: #require(fixture["05_authorization_epoch"])) + + for encoded in [enablement, rootData, helperEpochData, initialAuthorization, authorizationEpoch] { + #expect(try DiagnosticsDeterministicCBOR.encode(DiagnosticsDeterministicCBOR.decode(encoded)) == encoded) + } + + let enablementValue = try DiagnosticsDeterministicCBOR.decode(enablement) + var initialRecord = makeRuntimeRecord( + appSeed: Data(repeating: 0x31, count: 32), + helperPublic: try #require(enablementValue.bytes(for: 13, count: 32)), + homeserver: try #require(enablementValue.bytes(for: 5, count: 32)), + folder: try #require(enablementValue.bytes(for: 6, count: 32)), + appEpoch: try #require(enablementValue.unsigned(for: 12)), + helperEpoch: try #require(enablementValue.unsigned(for: 15)) + ) + initialRecord.currentCredentialStateDigest = Data(repeating: 0x25, count: 32) + let reproducedEnablement = try DiagnosticsNamespaceProtocol.makeEnablement( + record: initialRecord, + appKey: Curve25519.Signing.PrivateKey(rawRepresentation: initialRecord.appSeed), + nonce: try #require(enablementValue.bytes(for: 19, count: 32)), + now: Date(timeIntervalSince1970: TimeInterval(try #require(enablementValue.unsigned(for: 26)))) + ) + let reproducedEnablementBody = try removingSignatures(reproducedEnablement, labels: [253]) + let expectedEnablementBody = try removingSignatures(enablement, labels: [253]) + #expect(reproducedEnablementBody == expectedEnablementBody) + + let root = try DiagnosticsNamespaceProtocol.validateRootManifest( + rootData, + enablement: enablement, + record: initialRecord + ) + let helperEpochValue = try DiagnosticsDeterministicCBOR.decode(helperEpochData) + var currentRecord = initialRecord + currentRecord.helperPublicKey = try #require(helperEpochValue.bytes(for: 13, count: 32)) + currentRecord.helperKeyID = try #require(helperEpochValue.bytes(for: 14, count: 32)) + currentRecord.helperEpoch = try #require(helperEpochValue.unsigned(for: 15)) + let manifestDigest = try DiagnosticsNamespaceProtocol.validateHelperEpochManifest( + helperEpochData, + rootData: rootData, + priorManifestData: rootData, + record: currentRecord + ) + let currentRoot = DiagnosticsNamespaceProtocol.RootManifest( + message: root.message, + namespaceID: root.namespaceID, + rootDigest: root.rootDigest, + manifestDigest: manifestDigest + ) + + let initialValue = try DiagnosticsDeterministicCBOR.decode(initialAuthorization) + currentRecord.currentCredentialStateDigest = try #require(initialValue.bytes(for: 25, count: 32)) + let initialCandidate = try DiagnosticsNamespaceProtocol.makeInitialAuthorization( + record: currentRecord, + root: currentRoot, + appKey: Curve25519.Signing.PrivateKey(rawRepresentation: currentRecord.appSeed), + nonce: try #require(initialValue.bytes(for: 30, count: 32)), + now: Date(timeIntervalSince1970: TimeInterval(try #require(initialValue.unsigned(for: 26)))) + ) + let expectedInitialCandidate = try removingSignatures(initialAuthorization, labels: [255]) + let generatedInitialBody = try removingSignatures(initialCandidate.message, labels: [253]) + let expectedInitialBody = try removingSignatures(initialAuthorization, labels: [253, 255]) + #expect(generatedInitialBody == expectedInitialBody) + let fixtureInitialCandidate = DiagnosticsNamespaceProtocol.AuthorizationCandidate( + message: expectedInitialCandidate, + installationBinding: initialCandidate.installationBinding + ) + let initialDigest = try DiagnosticsNamespaceProtocol.validateCompletedAuthorization( + initialAuthorization, + candidate: fixtureInitialCandidate, + record: currentRecord, + root: currentRoot + ) + + let epochValue = try DiagnosticsDeterministicCBOR.decode(authorizationEpoch) + currentRecord.appSeed = Data(repeating: 0x32, count: 32) + currentRecord.appPublicKey = try #require(epochValue.bytes(for: 10, count: 32)) + currentRecord.appKeyID = try #require(epochValue.bytes(for: 11, count: 32)) + currentRecord.appEpoch = try #require(epochValue.unsigned(for: 12)) + currentRecord.currentCredentialStateDigest = try #require(epochValue.bytes(for: 25, count: 32)) + guard let initialAppKeyID = epochValue.bytes(for: 9, count: 32) else { + throw DiagnosticsProtocolError.invalidMessage + } + currentRecord.namespaceInitialAppKeyID = initialAppKeyID + currentRecord.namespaceAuthorizationDigest = initialDigest + currentRecord.namespaceAuthorizationEpoch = 1 + let epochCandidate = try DiagnosticsNamespaceProtocol.makeAuthorizationEpoch( + record: currentRecord, + root: currentRoot, + priorAuthorizationDigest: initialDigest, + appKey: Curve25519.Signing.PrivateKey(rawRepresentation: currentRecord.appSeed), + nonce: try #require(epochValue.bytes(for: 30, count: 32)), + now: Date(timeIntervalSince1970: TimeInterval(try #require(epochValue.unsigned(for: 26)))) + ) + let expectedEpochCandidate = try removingSignatures(authorizationEpoch, labels: [255]) + let generatedEpochBody = try removingSignatures(epochCandidate.message, labels: [253]) + let expectedEpochBody = try removingSignatures(authorizationEpoch, labels: [253, 255]) + #expect(generatedEpochBody == expectedEpochBody) + let fixtureEpochCandidate = DiagnosticsNamespaceProtocol.AuthorizationCandidate( + message: expectedEpochCandidate, + installationBinding: epochCandidate.installationBinding + ) + let epochDigest = try DiagnosticsNamespaceProtocol.validateCompletedAuthorizationEpoch( + authorizationEpoch, + candidate: fixtureEpochCandidate, + record: currentRecord, + root: currentRoot + ) + + let expectedPath = M1ContractCrypto.base32LowerNoPadding(initialCandidate.installationBinding) + #expect( + try DiagnosticsNamespaceProtocol.authorizationRelativePath( + installationBinding: initialCandidate.installationBinding + ) == "installations/\(expectedPath)/authorization.cbor" + ) + #expect( + try DiagnosticsNamespaceProtocol.authorizationEpochRelativePath( + installationBinding: initialCandidate.installationBinding, + epoch: 2 + ) == "installations/\(expectedPath)/authorization-epochs/2.authorization.cbor" + ) + + // A later app-key rotation does not require or append another helper + // manifest when the stored helper-manifest epoch is already current. + currentRecord.namespaceID = currentRoot.namespaceID + currentRecord.namespaceEnablement = enablement + currentRecord.namespaceRootDigest = currentRoot.rootDigest + currentRecord.namespaceManifestDigest = currentRoot.manifestDigest + currentRecord.namespaceManifestEpoch = currentRecord.helperEpoch + currentRecord.namespaceAuthorizationDigest = epochDigest + currentRecord.namespaceAuthorizationEpoch = 2 + currentRecord.state = .namespaceActive + currentRecord.lastOutgoing = epochCandidate.message + currentRecord.lastIncoming = authorizationEpoch + + let identifier = UUID().uuidString.lowercased() + let support = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-manifest-epoch-\(identifier)", isDirectory: true) + let folder = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-manifest-folder-\(identifier)", isDirectory: true) + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.v1.tests.\(identifier)", + keychain: InMemoryDiagnosticsKeychain() + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + try? FileManager.default.removeItem(at: folder) + } + _ = try store.installationCredential() + try store.save(currentRecord) + let namespace = folder.appendingPathComponent( + DiagnosticsNamespaceProtocol.rootName, + isDirectory: true + ) + try FileManager.default.createDirectory(at: namespace, withIntermediateDirectories: true) + try rootData.write( + to: namespace.appendingPathComponent(DiagnosticsNamespaceProtocol.rootManifestName), + options: .atomic + ) + let helper = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(repeating: 0x42, count: 32) + ) + #expect(helper.publicKey.rawRepresentation == currentRecord.helperPublicKey) + let transport = LifecycleTransport( + currentHelper: helper, + proposedHelper: nil, + capabilityHelper: helper, + now: Date(timeIntervalSince1970: 1_700_000_000) + ) + let controller = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { Date(timeIntervalSince1970: 1_700_000_000) }, + // At this representable value, `(value + 300) - value` rounds + // above 300. Deadline validation must compare absolute bounds. + continuousNow: { 7_892.486_404_694_878 } + ) + controller.refresh() + await controller.startAppKeyRotation(recordID: currentRecord.id) + #expect(controller.records.first?.state == .namespaceAuthorizationRefreshRequired) + await controller.continueNamespaceAuthorizationRefresh( + recordID: currentRecord.id, + currentFolderPath: folder.path + ) + #expect(controller.lastError == nil) + #expect(controller.records.first?.state == .namespaceAuthorizationRefreshPrepared) + #expect(controller.records.first?.namespaceManifestEpoch == currentRecord.helperEpoch) + #expect(!FileManager.default.fileExists( + atPath: namespace.appendingPathComponent("manifest-epochs", isDirectory: true).path + )) + } + + @Test("Pairing advances only after fingerprint confirmation and survives app restart") + @MainActor + func explicitPairingStateMachine() async throws { + let identifier = UUID().uuidString.lowercased() + let support = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-pairing-\(identifier)", isDirectory: true) + let keychain = InMemoryDiagnosticsKeychain() + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.v1.tests.\(identifier)", + keychain: keychain + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let helperKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x41, count: 32)) + let transport = BootstrapPairingTransport(helperKey: helperKey, issuedAt: 1_700_000_000) + let controller = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { now } + ) + controller.refresh() + #expect(controller.records.isEmpty) + #expect(!controller.hasInstallationMarker) + #expect(!controller.hasInstallationCredential) + + let deviceID = "P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2" + let folderID = "folder-alpha" + let invitation = try makePairingInvitation( + helperKey: helperKey, + deviceID: deviceID, + folderID: folderID, + issuedAt: 1_700_000_000 + ) + await controller.beginPairing( + qr: DiagnosticsCrypto.base64URLEncode(invitation), + homeserverDeviceID: deviceID, + folderID: folderID + ) + #expect(controller.lastError == nil) + let pending = try #require(controller.records.first) + #expect(pending.state == .acceptanceReceived) + #expect(pending.namespaceID == nil) + #expect(pending.namespaceEnablement == nil) + #expect(pending.namespaceAuthorizationEpoch == 0) + guard case .fingerprint(let recordID, let fingerprint) = controller.notice else { + Issue.record("Pairing became active without an explicit transcript comparison") + return + } + #expect(recordID == pending.id) + #expect(fingerprint.count == 12) + let initialTypes = await transport.observedTypes() + #expect(initialTypes == [.appRequest]) + + await controller.confirmFingerprintAndActivate(recordID: pending.id) + #expect(controller.lastError == nil) + #expect(controller.records.first?.state == .active) + #expect(controller.records.first?.namespaceID == nil) + let activeTypes = await transport.observedTypes() + #expect(activeTypes == [.appRequest, .finalize, .receipt, .activate]) + + let restarted = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { now } + ) + restarted.refresh() + #expect(restarted.records.count == 1) + #expect(restarted.records.first?.state == .active) + #expect(restarted.records.first?.transcriptFingerprint == fingerprint) + #expect(restarted.hasInstallationMarker) + #expect(restarted.hasInstallationCredential) + } + + @Test("Pre-activation cancellation is signed, retryable, and removes only pending local state") + @MainActor + func explicitPairingCancellation() async throws { + let identifier = UUID().uuidString.lowercased() + let support = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-cancel-\(identifier)", isDirectory: true) + let keychain = InMemoryDiagnosticsKeychain() + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.v1.tests.\(identifier)", + keychain: keychain + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + } + let now = Date(timeIntervalSince1970: 1_700_000_000) + let helperKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x41, count: 32)) + let transport = BootstrapPairingTransport(helperKey: helperKey, issuedAt: 1_700_000_000) + let controller = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { now } + ) + let deviceID = "P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2" + let invitation = try makePairingInvitation( + helperKey: helperKey, + deviceID: deviceID, + folderID: "folder-alpha", + issuedAt: 1_700_000_000 + ) + await controller.beginPairing( + qr: DiagnosticsCrypto.base64URLEncode(invitation), + homeserverDeviceID: deviceID, + folderID: "folder-alpha" + ) + let recordID = try #require(controller.records.first?.id) + await controller.cancelPendingPairing(recordID: recordID) + #expect(controller.lastError == nil) + #expect(controller.records.isEmpty) + let types = await transport.observedTypes() + #expect(types == [.appRequest, .abort]) + let inspection = try store.inspection() + #expect(inspection.hasMarker) + #expect(inspection.hasCredential) + #expect(inspection.records.isEmpty) + } + + @Test("Namespace activation requires separate operator creation and synchronized countersignature") + @MainActor + func explicitNamespaceStateMachine() async throws { + let identifier = UUID().uuidString.lowercased() + let support = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-namespace-store-\(identifier)", isDirectory: true) + let folder = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-namespace-folder-\(identifier)", isDirectory: true) + let keychain = InMemoryDiagnosticsKeychain() + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.v1.tests.\(identifier)", + keychain: keychain + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + try? FileManager.default.removeItem(at: folder) + } + _ = try store.installationCredential() + let appSeed = Data(repeating: 0x31, count: 32) + let helperKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x41, count: 32)) + let record = makeRuntimeRecord( + appSeed: appSeed, + helperPublic: helperKey.publicKey.rawRepresentation, + homeserver: Data(repeating: 0x05, count: 32), + folder: Data(repeating: 0x06, count: 32) + ) + try store.save(record) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let transport = NamespaceControlTransport(record: record, helperKey: helperKey) + let controller = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { now }, + continuousNow: { 7_892.486_404_694_878 } + ) + controller.refresh() + await controller.checkCapability(recordID: record.id) + #expect(controller.capabilityStates[record.id] == .available) + + await controller.requestNamespaceEnablement(recordID: record.id) + #expect(controller.lastError == nil) + #expect(controller.records.first?.state == .namespaceAwaitingOperator) + #expect(controller.records.first?.namespaceID == nil) + await controller.startAppKeyRotation(recordID: record.id) + #expect(controller.lastError == .unavailable) + #expect(controller.records.first?.state == .namespaceAwaitingOperator) + #expect(controller.records.first?.pendingLifecycle == nil) + let capturedEnablement = await transport.latestEnablement() + let enablement = try #require(capturedEnablement) + + // This filesystem mutation represents the distinct, explicit helper + // operator step. The app controller itself never creates this path. + let namespace = folder.appendingPathComponent(DiagnosticsNamespaceProtocol.rootName, isDirectory: true) + try FileManager.default.createDirectory(at: namespace, withIntermediateDirectories: true) + let m4Fixture = try loadDiagnosticsHexFixture(named: "diagnostics-namespace-m4") + let goldenRoot = try DiagnosticsDeterministicCBOR.decode( + Data(m1Hex: #require(m4Fixture["02_root_manifest"])) + ) + let readmeDigest = try #require(goldenRoot.bytes(for: 29, count: 32)) + let rootData = try makeNamespaceRoot( + enablement: enablement, + record: record, + helperKey: helperKey, + readmeDigest: readmeDigest, + createdAt: 1_700_000_020 + ) + let lateRoot = try makeNamespaceRoot( + enablement: enablement, + record: record, + helperKey: helperKey, + readmeDigest: readmeDigest, + createdAt: 1_700_000_301 + ) + expectDiagnosticsError(.invalidMessage) { + _ = try DiagnosticsNamespaceProtocol.validateRootManifest( + lateRoot, + enablement: enablement, + record: record + ) + } + try rootData.write( + to: namespace.appendingPathComponent(DiagnosticsNamespaceProtocol.rootManifestName), + options: .atomic + ) + + await controller.continueNamespace(recordID: record.id, currentFolderPath: folder.path) + #expect(controller.lastError == nil) + #expect(controller.records.first?.state == .namespaceAuthorizationPrepared) + let capturedAuthorization = await transport.latestAuthorization() + let candidate = try #require(capturedAuthorization) + let completed = try countersignInitialAuthorization(candidate, helperKey: helperKey) + let candidateValue = try DiagnosticsDeterministicCBOR.decode(candidate) + let installation = try #require(candidateValue.bytes(for: 8, count: 32)) + let relative = try DiagnosticsNamespaceProtocol.authorizationRelativePath( + installationBinding: installation + ) + let authorizationURL = namespace.appendingPathComponent(relative) + try FileManager.default.createDirectory( + at: authorizationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try completed.write(to: authorizationURL, options: .atomic) + + await controller.continueNamespace(recordID: record.id, currentFolderPath: folder.path) + #expect(controller.lastError == nil) + let active = try #require(controller.records.first) + #expect(active.state == .namespaceActive) + #expect(active.namespaceAuthorizationEpoch == 1) + #expect(active.namespaceAuthorizationDigest == DiagnosticsNamespaceProtocol.recordDigest(completed)) + let paths = await transport.observedPaths() + #expect(paths == [ + DiagnosticsCapabilityProtocol.path, + DiagnosticsNamespaceProtocol.enablementPath, + DiagnosticsNamespaceProtocol.authorizationPath, + DiagnosticsNamespaceProtocol.authorizationPath, + ]) + #expect(!FileManager.default.fileExists( + atPath: namespace.appendingPathComponent("operations", isDirectory: true).path + )) + } + + @Test("App-key rotation confirms proposed capability before revocation") + @MainActor + func appKeyRotationAndRevocation() async throws { + let context = try LifecycleTestContext.make(label: "app-key") + defer { context.cleanup() } + let initialAppPublic = context.record.appPublicKey + let helperKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x41, count: 32)) + let transport = LifecycleTransport( + currentHelper: helperKey, + proposedHelper: nil, + capabilityHelper: helperKey, + now: Date(timeIntervalSince1970: 1_700_000_000) + ) + let controller = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in transport }, + now: { Date(timeIntervalSince1970: 1_700_000_000) }, + continuousNow: { 7_892.486_404_694_878 } + ) + controller.refresh() + await controller.startAppKeyRotation(recordID: context.record.id) + #expect(controller.lastError == nil) + let rotated = try #require(controller.records.first) + #expect(rotated.state == .active) + #expect(rotated.appEpoch == 2) + #expect(rotated.appPublicKey != initialAppPublic) + #expect(rotated.helperPublicKey == helperKey.publicKey.rawRepresentation) + #expect(rotated.pendingLifecycle == nil) + + await controller.revoke(recordID: rotated.id, reason: .userRequest) + #expect(controller.lastError == nil) + let revoked = try #require(controller.records.first) + #expect(revoked.state == .revoked) + #expect(revoked.appEpoch == 3) + let types = await transport.observedTypes() + #expect(types == [ + .appKeyRotationRequest, + .appKeyRotationNewProof, + .lifecycleFinalize, + .revocationRequest, + ]) + } + + @Test("App-key rotation reuses one staged installation key across folders") + @MainActor + func appKeyRotationSharesInstallationKey() async throws { + let context = try LifecycleTestContext.make(label: "app-key-multi-folder") + defer { context.cleanup() } + let second = makeRuntimeRecord( + appSeed: context.record.appSeed, + helperPublic: context.record.helperPublicKey, + homeserver: context.record.homeserverBinding, + folder: Data(repeating: 0x13, count: 32), + folderID: "folder-beta" + ) + try context.store.save(second) + + let controller = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in + FailingDiagnosticsTransport(error: .unavailable) + }, + now: { Date(timeIntervalSince1970: 1_700_000_000) } + ) + controller.refresh() + await controller.startAppKeyRotation(recordID: context.record.id) + #expect(controller.lastError == .unavailable) + let firstPending = try #require( + controller.records.first(where: { $0.id == context.record.id })?.pendingLifecycle + ) + let stagedSeed = try #require(firstPending.proposedAppSeed) + #expect(stagedSeed != context.record.appSeed) + #expect(try context.store.installationCredential().privateKey.rawRepresentation == stagedSeed) + + await controller.startAppKeyRotation(recordID: second.id) + #expect(controller.lastError == .unavailable) + let secondPending = try #require( + controller.records.first(where: { $0.id == second.id })?.pendingLifecycle + ) + #expect(secondPending.proposedAppSeed == stagedSeed) + } + + @Test("Helper-key rotation requires old/new proof and proposed-helper capability") + @MainActor + func helperKeyRotation() async throws { + let fixture = try loadDiagnosticsHexFixture(named: "diagnostics-pairing-m3") + let proposal = try Data(m1Hex: #require(fixture["14_helper_key_rotation_propose"])) + let proof = try Data(m1Hex: #require(fixture["15_helper_key_rotation_new_proof"])) + let proposalValue = try DiagnosticsDeterministicCBOR.decode(proposal) + let record = try lifecycleFixtureRecord(value: proposalValue) + let context = try LifecycleTestContext.make(label: "helper-key", record: record) + defer { context.cleanup() } + let currentHelper = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x41, count: 32)) + let proposedHelper = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x42, count: 32)) + let transport = LifecycleTransport( + currentHelper: currentHelper, + proposedHelper: proposedHelper, + capabilityHelper: proposedHelper, + now: Date(timeIntervalSince1970: 1_700_000_000) + ) + let controller = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in transport }, + now: { Date(timeIntervalSince1970: 1_700_000_000) } + ) + controller.refresh() + await controller.startHelperKeyRotation( + recordID: record.id, + proposal: DiagnosticsCrypto.base64URLEncode(proposal), + proof: DiagnosticsCrypto.base64URLEncode(proof) + ) + #expect(controller.lastError == nil) + let rotated = try #require(controller.records.first) + #expect(rotated.state == .active) + #expect(rotated.helperEpoch == 2) + #expect(rotated.helperPublicKey == proposedHelper.publicKey.rawRepresentation) + #expect(rotated.pendingLifecycle == nil) + let types = await transport.observedTypes() + #expect(types == [.helperKeyRotationConfirm, .lifecycleFinalize]) + } + + @Test("TLS-pin rotation switches only after proposed-pin capability") + @MainActor + func tlsPinRotation() async throws { + let fixture = try loadDiagnosticsHexFixture(named: "diagnostics-pairing-m3") + let proposal = try Data(m1Hex: #require(fixture["17_tls_pin_rotation_propose"])) + let proposalValue = try DiagnosticsDeterministicCBOR.decode(proposal) + let record = try lifecycleFixtureRecord(value: proposalValue) + let context = try LifecycleTestContext.make(label: "tls-pin", record: record) + defer { context.cleanup() } + let helper = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 0x41, count: 32)) + let transport = LifecycleTransport( + currentHelper: helper, + proposedHelper: nil, + capabilityHelper: helper, + now: Date(timeIntervalSince1970: 1_700_000_000) + ) + let controller = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in transport }, + now: { Date(timeIntervalSince1970: 1_700_000_000) } + ) + controller.refresh() + await controller.startTLSPinRotation( + recordID: record.id, + proposal: DiagnosticsCrypto.base64URLEncode(proposal) + ) + #expect(controller.lastError == nil) + let rotated = try #require(controller.records.first) + #expect(rotated.state == .active) + #expect(rotated.tlsSPKIPin == Data(repeating: 0x16, count: 32)) + #expect(rotated.pendingLifecycle == nil) + let types = await transport.observedTypes() + #expect(types == [.tlsPinRotationConfirm, .lifecycleFinalize]) + } + + @Test("Pre-commit credential rotation abort preserves the current authority") + @MainActor + func lifecycleAbort() async throws { + let context = try LifecycleTestContext.make(label: "lifecycle-abort") + defer { context.cleanup() } + let helper = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(repeating: 0x41, count: 32) + ) + let transport = AbortLifecycleTransport( + currentHelper: helper, + now: Date(timeIntervalSince1970: 1_700_000_000) + ) + let controller = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in transport }, + now: { Date(timeIntervalSince1970: 1_700_000_000) } + ) + controller.refresh() + await controller.startAppKeyRotation(recordID: context.record.id) + #expect(controller.lastError == .unavailable) + #expect(controller.records.first?.state == .lifecyclePending) + #expect(controller.records.first?.pendingLifecycle != nil) + + await controller.abortLifecycle(recordID: context.record.id) + #expect(controller.lastError == nil) + let restored = try #require(controller.records.first) + #expect(restored.state == .active) + #expect(restored.pendingLifecycle == nil) + #expect(restored.appEpoch == context.record.appEpoch) + #expect(restored.appPublicKey == context.record.appPublicKey) + #expect(controller.capabilityStates[restored.id] == .unavailable) + let types = await transport.observedTypes() + #expect(types == [ + .appKeyRotationRequest, + .appKeyRotationNewProof, + .appKeyRotationNewProof, + .lifecycleAbort, + ]) + } + + @Test("Persisted monotonic deadline survives restart and defeats wall-clock rollback") + @MainActor + func persistedMonotonicDeadline() async throws { + let context = try LifecycleTestContext.make(label: "monotonic-deadline") + defer { context.cleanup() } + let helper = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(repeating: 0x41, count: 32) + ) + let transport = AbortLifecycleTransport( + currentHelper: helper, + now: Date(timeIntervalSince1970: 1_700_000_000) + ) + let clock = LockedDiagnosticsClock( + Date(timeIntervalSince1970: 1_700_000_000), + continuous: 1_000 + ) + let controller = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in transport }, + now: { clock.value() }, + continuousNow: { clock.continuousValue() } + ) + controller.refresh() + await controller.startAppKeyRotation(recordID: context.record.id) + #expect(controller.lastError == .unavailable) + #expect(controller.records.first?.localDeadline != nil) + + clock.shiftWall(by: -100) + clock.advanceContinuous(by: 301) + let restarted = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in transport }, + now: { clock.value() }, + continuousNow: { clock.continuousValue() } + ) + restarted.refresh() + let pending = try #require(restarted.records.first) + #expect(restarted.canDiscardExpiredLifecycle(pending)) + restarted.discardExpiredLifecycle(recordID: pending.id) + #expect(restarted.lastError == nil) + #expect(restarted.records.first?.state == .active) + #expect(restarted.records.first?.localDeadline == nil) + } + + @Test("Expired capability evidence cannot authorize namespace enablement") + @MainActor + func capabilityExpiry() async throws { + let context = try LifecycleTestContext.make(label: "capability-expiry") + defer { context.cleanup() } + let helper = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(repeating: 0x41, count: 32) + ) + let transport = NamespaceControlTransport(record: context.record, helperKey: helper) + let clock = LockedDiagnosticsClock( + Date(timeIntervalSince1970: 1_700_000_000) + ) + let controller = DiagnosticsPairingController( + credentialStore: context.store, + transportFactory: { _, _, _ in transport }, + now: { clock.value() }, + continuousNow: { clock.continuousValue() } + ) + controller.refresh() + await controller.checkCapability(recordID: context.record.id) + #expect(controller.capabilityStates[context.record.id] == .available) + + clock.advance(by: 120) + await controller.requestNamespaceEnablement(recordID: context.record.id) + #expect(controller.lastError == .unavailable) + #expect(controller.capabilityStates[context.record.id] == .unavailable) + #expect(controller.records.first?.state == .active) + let paths = await transport.observedPaths() + #expect(paths == [DiagnosticsCapabilityProtocol.path]) + } + + @Test("Old or invalid helper capability remains honestly unavailable or unsupported") + @MainActor + func capabilityFailureStates() async throws { + let identifier = UUID().uuidString.lowercased() + let support = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-capability-\(identifier)", isDirectory: true) + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.v1.tests.\(identifier)", + keychain: InMemoryDiagnosticsKeychain() + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + } + let credential = try store.installationCredential() + let record = makeRuntimeRecord( + appSeed: credential.privateKey.rawRepresentation, + helperPublic: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, + homeserver: Data(repeating: 0x11, count: 32), + folder: Data(repeating: 0x12, count: 32) + ) + try store.save(record) + let now = Date(timeIntervalSince1970: 1_700_000_000) + + let unavailable = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in FailingDiagnosticsTransport(error: .unavailable) }, + now: { now } + ) + unavailable.refresh() + await unavailable.checkCapability(recordID: record.id) + #expect(unavailable.capabilityStates[record.id] == .unavailable) + #expect(unavailable.lastError == .unavailable) + #expect(unavailable.records.first?.state == .active) + + let unsupported = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in FailingDiagnosticsTransport(error: .invalidMessage) }, + now: { now } + ) + unsupported.refresh() + await unsupported.checkCapability(recordID: record.id) + #expect(unsupported.capabilityStates[record.id] == .unsupported) + #expect(unsupported.lastError == .invalidMessage) + #expect(unsupported.records.first?.state == .active) + } + + @Test("Existing-user inspection is non-mutating and explicit credentials are device-only") + @MainActor + func keychainAndExistingUserBoundary() throws { + let identifier = UUID().uuidString.lowercased() + let service = "eu.vaultsync.app.diagnostics.v1.tests.\(identifier)" + let support = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-keychain-\(identifier)", isDirectory: true) + let keychain = InMemoryDiagnosticsKeychain() + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: service, + keychain: keychain + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + } + try? store.resetForExplicitRepair() + + let initial = try store.inspection() + #expect(!initial.hasMarker) + #expect(!initial.hasCredential) + #expect(initial.records.isEmpty) + #expect(!FileManager.default.fileExists(atPath: support.path)) + #expect(keychain.attributes(account: "installation-key-v1") == nil) + + let orphanRecord = makeRuntimeRecord( + appSeed: Data(repeating: 0x31, count: 32), + helperPublic: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, + homeserver: Data(repeating: 0x11, count: 32), + folder: Data(repeating: 0x12, count: 32) + ) + try store.save(orphanRecord) + expectDiagnosticsError(.recoveryRequired) { + _ = try store.inspection() + } + try store.delete(orphanRecord) + + let first = try store.installationCredential() + let second = try store.installationCredential() + #expect(first.privateKey.rawRepresentation == second.privateKey.rawRepresentation) + #expect(first.markerDigest == second.markerDigest) + let attributes = try #require(keychain.attributes(account: "installation-key-v1")) + #expect( + attributes[kSecAttrAccessible as String] as? String + == kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String + ) + #expect((attributes[kSecAttrSynchronizable as String] as? Bool) != true) + #expect(attributes[kSecAttrAccessGroup as String] == nil) + + let storedRecord = makeRuntimeRecord( + appSeed: first.privateKey.rawRepresentation, + helperPublic: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, + homeserver: Data(repeating: 0x11, count: 32), + folder: Data(repeating: 0x12, count: 32) + ) + try store.save(storedRecord) + let populated = try store.inspection() + #expect(populated.hasMarker) + #expect(populated.hasCredential) + #expect(populated.records.map(\.id) == [storedRecord.id]) + + let controller = DiagnosticsPairingController(credentialStore: store) + controller.refresh() + #expect(controller.records.map(\.id) == [storedRecord.id]) + let lostCredentialQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "installation-key-v1", + kSecAttrSynchronizable as String: kCFBooleanFalse!, + ] + #expect(keychain.delete(lostCredentialQuery as CFDictionary) == errSecSuccess) + controller.refresh() + #expect(controller.records.isEmpty) + #expect(!controller.hasInstallationMarker) + #expect(!controller.hasInstallationCredential) + #expect(controller.notice == .recoveryRequired) + #expect(controller.lastError == .recoveryRequired) + + try store.resetForExplicitRepair() + let reset = try store.inspection() + #expect(!reset.hasMarker) + #expect(!reset.hasCredential) + #expect(reset.records.isEmpty) + } + + @Test("Syncthing bindings and endpoint literals fail closed") + func targetBindingValidation() throws { + let deviceID = "P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2" + #expect(try DiagnosticsSyncthingBinding.rawDeviceID(deviceID).count == 32) + expectDiagnosticsError(.unsupported) { + _ = try DiagnosticsSyncthingBinding.rawDeviceID(String(deviceID.dropLast()) + "3") + } + expectDiagnosticsError(.unsupported) { + _ = try DiagnosticsSyncthingBinding.rawDeviceID(deviceID.lowercased()) + } + expectDiagnosticsError(.unsupported) { + _ = try DiagnosticsSyncthingBinding.rawDeviceID(deviceID.replacingOccurrences(of: "-", with: "")) + } + #expect(DiagnosticsPinnedTransport.isCanonicalIPAddress("127.0.0.1")) + #expect(!DiagnosticsPinnedTransport.isCanonicalIPAddress("127.000.000.001")) + #expect(DiagnosticsPinnedTransport.isCanonicalIPAddress("2001:db8::1")) + #expect(!DiagnosticsPinnedTransport.isCanonicalIPAddress("2001:0db8::1")) + } + + @Test("Pinned transport accepts only the four fixed M3 control paths") + func fixedTransportPaths() async throws { + let transport = try DiagnosticsPinnedTransport( + host: "127.0.0.1", + port: 8443, + pin: Data(repeating: 0x11, count: 32) + ) + for path in [ + "/api/v1/diagnostics/../pairing", + "/api/v1/diagnostics/pairing?record=1", + "/api/v1/diagnostics/artifact", + "/api/v1/diagnostics/namespace/authorization/extra", + ] { + do { + _ = try await transport.post(path: path, body: Data([0xa0]), responseBody: true) + Issue.record("Unexpectedly accepted non-M3 diagnostics path") + } catch let error as DiagnosticsProtocolError { + #expect(error == .invalidMessage) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + } + + @Test("Pinned trust and missing-folder actions fail closed") + func pinnedTrustAndMissingFolderSourceBoundaries() throws { + let iosDirectory = URL(fileURLWithPath: "\(#filePath)") + .deletingLastPathComponent() + .deletingLastPathComponent() + let transport = try String( + contentsOf: iosDirectory + .appendingPathComponent("VaultSync", isDirectory: true) + .appendingPathComponent("Services", isDirectory: true) + .appendingPathComponent("DiagnosticsPinnedTransport.swift"), + encoding: .utf8 + ) + let pinMatch = try #require(transport.range(of: "guard Self.constantTimeEqual")) + let trustEvaluation = try #require( + transport.range(of: "DiagnosticsPinnedTrustEvaluator.evaluate", range: pinMatch.upperBound.. Date { + lock.lock() + defer { lock.unlock() } + return date + } + + func advance(by interval: TimeInterval) { + lock.lock() + date = date.addingTimeInterval(interval) + continuous += interval + lock.unlock() + } + + func continuousValue() -> TimeInterval { + lock.lock() + defer { lock.unlock() } + return continuous + } + + func advanceContinuous(by interval: TimeInterval) { + lock.lock() + continuous += interval + lock.unlock() + } + + func shiftWall(by interval: TimeInterval) { + lock.lock() + date = date.addingTimeInterval(interval) + lock.unlock() + } +} + +private func loadDiagnosticsHexFixture( + named name: String, + filePath: StaticString = #filePath +) throws -> [String: String] { + let bundle = Bundle(for: DiagnosticsAppRuntimeFixtureToken.self) + let bundled = bundle.url(forResource: name, withExtension: "json") + let fallback = URL(fileURLWithPath: "\(filePath)") + .deletingLastPathComponent() + .appendingPathComponent("Fixtures", isDirectory: true) + .appendingPathComponent("\(name).json") + return try JSONDecoder().decode([String: String].self, from: Data(contentsOf: bundled ?? fallback)) +} + +private func makeRuntimeRecord( + appSeed: Data, + helperPublic: Data, + homeserver: Data, + folder: Data, + folderID: String = "folder-alpha", + appEpoch: UInt64 = 1, + helperEpoch: UInt64 = 1 +) -> DiagnosticsPairingRecord { + let appKey = try! Curve25519.Signing.PrivateKey(rawRepresentation: appSeed) + let appPublic = appKey.publicKey.rawRepresentation + let appID = DiagnosticsCrypto.keyID(publicKey: appPublic) + let helperID = DiagnosticsCrypto.keyID(publicKey: helperPublic) + return DiagnosticsPairingRecord( + id: DiagnosticsPairingRecord.identifier(appKeyID: appID, folderBinding: folder), + homeserverDeviceID: "P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2", + folderID: folderID, + endpointHost: "127.0.0.1", + endpointPort: 443, + tlsSPKIPin: Data(repeating: 0x55, count: 32), + helperPublicKey: helperPublic, + helperKeyID: helperID, + homeserverBinding: homeserver, + folderBinding: folder, + appSeed: appSeed, + appPublicKey: appPublic, + appKeyID: appID, + appEpoch: appEpoch, + helperEpoch: helperEpoch, + currentCredentialStateDigest: Data(repeating: 0x25, count: 32), + state: .active, + hardExpiry: 1_800_000_000, + localDeadline: nil, + lastOutgoing: Data([0xa0]), + lastIncoming: nil, + transcriptFingerprint: nil, + namespaceID: nil, + namespaceInitialAppKeyID: nil, + namespaceEnablement: nil, + namespaceRootDigest: nil, + namespaceManifestDigest: nil, + namespaceManifestEpoch: nil, + namespaceAuthorizationDigest: nil, + namespaceAuthorizationEpoch: 0, + pendingLifecycle: nil + ) +} + +private func makeCapabilityResponse( + query: DiagnosticsCapabilityProtocol.Query, + record: DiagnosticsPairingRecord, + helperKey: Curve25519.Signing.PrivateKey, + flags: UInt64, + issuedAt: UInt64 +) throws -> Data { + let body = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(DiagnosticsCapabilityProtocol.capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(2)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 8, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 9, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 10, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 12, value: .unsigned(issuedAt)), + DiagnosticsCBORField(label: 13, value: .unsigned(issuedAt + 120)), + DiagnosticsCBORField(label: 27, value: .unsigned(flags)), + DiagnosticsCBORField(label: 30, value: .bytes(query.nonce)), + DiagnosticsCBORField(label: 31, value: .bytes(query.digest)), + ]) + let encodedBody = try DiagnosticsDeterministicCBOR.encode(body) + var input = Data("eu.vaultsync.roundtrip/v1/capability-response\0".utf8) + input.append(encodedBody) + let signature = try helperKey.signature(for: input) + guard case .map(var fields) = body else { throw DiagnosticsProtocolError.invalidMessage } + fields.append(DiagnosticsCBORField(label: 255, value: .bytes(signature))) + return try DiagnosticsDeterministicCBOR.encode(.map(fields)) +} + +private func makeCapabilityResponseForQuery( + _ queryData: Data, + helperKey: Curve25519.Signing.PrivateKey +) throws -> Data { + let query = try DiagnosticsDeterministicCBOR.decode(queryData) + let copiedLabels: Set = [1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 30] + guard let queryFields = query.fields, + query.bytes(for: 30, count: 32) != nil else { + throw DiagnosticsProtocolError.invalidMessage + } + let queryBody = try DiagnosticsDeterministicCBOR.encode(query.removing(labels: [255])) + let queryDigest = DiagnosticsCrypto.sha256( + domain: "eu.vaultsync.roundtrip/v1/capability-query\0", + body: queryBody + ) + var fields = queryFields.filter { copiedLabels.contains($0.label) } + fields.append(DiagnosticsCBORField(label: 4, value: .unsigned(2))) + fields.append(DiagnosticsCBORField( + label: 27, + value: .unsigned(DiagnosticsCapabilityProtocol.requiredFlags) + )) + fields.append(DiagnosticsCBORField(label: 31, value: .bytes(queryDigest))) + let body = DiagnosticsCBORValue.map(fields) + let encodedBody = try DiagnosticsDeterministicCBOR.encode(body) + var input = Data("eu.vaultsync.roundtrip/v1/capability-response\0".utf8) + input.append(encodedBody) + let signature = try helperKey.signature(for: input) + guard case .map(var signedFields) = body else { throw DiagnosticsProtocolError.invalidMessage } + signedFields.append(DiagnosticsCBORField(label: 255, value: .bytes(signature))) + return try DiagnosticsDeterministicCBOR.encode(.map(signedFields)) +} + +private struct LifecycleTestContext { + let store: DiagnosticsCredentialStore + let support: URL + let record: DiagnosticsPairingRecord + + static func make( + label: String, + record suppliedRecord: DiagnosticsPairingRecord? = nil + ) throws -> LifecycleTestContext { + let identifier = "\(label)-\(UUID().uuidString.lowercased())" + let support = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("vaultsync-m3-lifecycle-\(identifier)", isDirectory: true) + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.v1.tests.\(identifier)", + keychain: InMemoryDiagnosticsKeychain() + ) + let installation = try store.installationCredential() + let record: DiagnosticsPairingRecord + if let suppliedRecord { + record = suppliedRecord + } else { + let helper = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(repeating: 0x41, count: 32) + ) + record = makeRuntimeRecord( + appSeed: installation.privateKey.rawRepresentation, + helperPublic: helper.publicKey.rawRepresentation, + homeserver: Data(repeating: 0x11, count: 32), + folder: Data(repeating: 0x12, count: 32) + ) + } + try store.save(record) + return LifecycleTestContext(store: store, support: support, record: record) + } + + func cleanup() { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + } +} + +private func lifecycleFixtureRecord(value: DiagnosticsCBORValue) throws -> DiagnosticsPairingRecord { + let appSeed = Data(repeating: 0x31, count: 32) + let helperPublic = try requiredDiagnosticsBytes(value, label: 11) + var record = makeRuntimeRecord( + appSeed: appSeed, + helperPublic: helperPublic, + homeserver: try requiredDiagnosticsBytes(value, label: 5), + folder: try requiredDiagnosticsBytes(value, label: 6), + appEpoch: try requiredDiagnosticsUnsigned(value, label: 17), + helperEpoch: try requiredDiagnosticsUnsigned(value, label: 19) + ) + guard record.appPublicKey == value.bytes(for: 7, count: 32), + record.appKeyID == value.bytes(for: 8, count: 32), + record.helperKeyID == value.bytes(for: 12, count: 32) else { + throw DiagnosticsProtocolError.invalidMessage + } + record.tlsSPKIPin = try requiredDiagnosticsBytes(value, label: 15) + record.currentCredentialStateDigest = try requiredDiagnosticsBytes(value, label: 26) + record.lastOutgoing = try DiagnosticsDeterministicCBOR.encode(value) + return record +} + +private func requiredDiagnosticsBytes( + _ value: DiagnosticsCBORValue, + label: UInt64 +) throws -> Data { + guard let data = value.bytes(for: label, count: 32) else { + throw DiagnosticsProtocolError.invalidMessage + } + return data +} + +private func requiredDiagnosticsUnsigned( + _ value: DiagnosticsCBORValue, + label: UInt64 +) throws -> UInt64 { + guard let number = value.unsigned(for: label) else { + throw DiagnosticsProtocolError.invalidMessage + } + return number +} + +private func makePairingInvitation( + helperKey: Curve25519.Signing.PrivateKey, + deviceID: String, + folderID: String, + issuedAt: UInt64 +) throws -> Data { + let helperPublic = helperKey.publicKey.rawRepresentation + let value = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(DiagnosticsPairingProtocol.capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(DiagnosticsPairingProtocol.MessageType.qr.rawValue)), + DiagnosticsCBORField(label: 5, value: .bytes(Data(repeating: 0x05, count: 32))), + DiagnosticsCBORField(label: 6, value: .text("helper.test")), + DiagnosticsCBORField(label: 7, value: .unsigned(8443)), + DiagnosticsCBORField(label: 8, value: .bytes(Data(repeating: 0x08, count: 32))), + DiagnosticsCBORField(label: 9, value: .bytes(helperPublic)), + DiagnosticsCBORField(label: 10, value: .bytes(DiagnosticsCrypto.keyID(publicKey: helperPublic))), + DiagnosticsCBORField(label: 11, value: .bytes(Data(repeating: 0x11, count: 32))), + DiagnosticsCBORField(label: 12, value: .bytes(Data(repeating: 0x12, count: 32))), + DiagnosticsCBORField(label: 13, value: .bytes(try DiagnosticsSyncthingBinding.deviceDigest(deviceID))), + DiagnosticsCBORField(label: 14, value: .bytes(try DiagnosticsSyncthingBinding.folderDigest(folderID))), + DiagnosticsCBORField(label: 15, value: .unsigned(issuedAt)), + DiagnosticsCBORField(label: 16, value: .unsigned(issuedAt + DiagnosticsPairingProtocol.maximumLifetime)), + DiagnosticsCBORField(label: 17, value: .bytes(Data(repeating: 0x17, count: 32))), + DiagnosticsCBORField(label: 24, value: .unsigned(1)), + ]) + let encoded = try DiagnosticsDeterministicCBOR.encode(value) + _ = try DiagnosticsPairingProtocol.decode(encoded) + return encoded +} + +private actor BootstrapPairingTransport: DiagnosticsTransporting { + private let helperKey: Curve25519.Signing.PrivateKey + private let issuedAt: UInt64 + private var receivedTypes: [DiagnosticsPairingProtocol.MessageType] = [] + + init(helperKey: Curve25519.Signing.PrivateKey, issuedAt: UInt64) { + self.helperKey = helperKey + self.issuedAt = issuedAt + } + + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { + guard path == DiagnosticsPairingProtocol.path, responseBody else { + throw DiagnosticsProtocolError.invalidMessage + } + let prior = try DiagnosticsPairingProtocol.decode(body) + receivedTypes.append(prior.type) + let responseType: DiagnosticsPairingProtocol.MessageType + switch prior.type { + case .appRequest: responseType = .helperAccept + case .finalize: responseType = .finalizeAck + case .receipt: responseType = .readyAck + case .activate: responseType = .activeAck + case .abort: responseType = .abortAck + default: throw DiagnosticsProtocolError.invalidMessage + } + return try makeHelperBootstrapResponse( + prior: prior, + responseType: responseType, + helperKey: helperKey, + issuedAt: issuedAt + ) + } + + func observedTypes() -> [DiagnosticsPairingProtocol.MessageType] { + receivedTypes + } +} + +private struct FailingDiagnosticsTransport: DiagnosticsTransporting { + let error: DiagnosticsProtocolError + + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { + throw error + } +} + +private actor NamespaceControlTransport: DiagnosticsTransporting { + private let record: DiagnosticsPairingRecord + private let helperKey: Curve25519.Signing.PrivateKey + private var paths: [String] = [] + private var enablement: Data? + private var authorization: Data? + + init(record: DiagnosticsPairingRecord, helperKey: Curve25519.Signing.PrivateKey) { + self.record = record + self.helperKey = helperKey + } + + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { + paths.append(path) + switch path { + case DiagnosticsCapabilityProtocol.path: + guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } + let value = try DiagnosticsDeterministicCBOR.decode(body) + let unsigned = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + guard let nonce = value.bytes(for: 30, count: 32), + let issuedAt = value.unsigned(for: 12) else { + throw DiagnosticsProtocolError.invalidMessage + } + let query = DiagnosticsCapabilityProtocol.Query( + message: body, + digest: DiagnosticsCrypto.sha256( + domain: "eu.vaultsync.roundtrip/v1/capability-query\0", + body: unsigned + ), + nonce: nonce + ) + return try makeCapabilityResponse( + query: query, + record: record, + helperKey: helperKey, + flags: DiagnosticsCapabilityProtocol.requiredFlags, + issuedAt: issuedAt + ) + case DiagnosticsNamespaceProtocol.enablementPath: + guard !responseBody else { throw DiagnosticsProtocolError.invalidMessage } + enablement = body + return nil + case DiagnosticsNamespaceProtocol.authorizationPath: + guard !responseBody else { throw DiagnosticsProtocolError.invalidMessage } + authorization = body + return nil + default: + throw DiagnosticsProtocolError.invalidMessage + } + } + + func latestEnablement() -> Data? { enablement } + func latestAuthorization() -> Data? { authorization } + func observedPaths() -> [String] { paths } +} + +private actor LifecycleTransport: DiagnosticsTransporting { + private let currentHelper: Curve25519.Signing.PrivateKey + private let proposedHelper: Curve25519.Signing.PrivateKey? + private let capabilityHelper: Curve25519.Signing.PrivateKey + private let now: Date + private var types: [DiagnosticsPairingProtocol.MessageType] = [] + + init( + currentHelper: Curve25519.Signing.PrivateKey, + proposedHelper: Curve25519.Signing.PrivateKey?, + capabilityHelper: Curve25519.Signing.PrivateKey, + now: Date + ) { + self.currentHelper = currentHelper + self.proposedHelper = proposedHelper + self.capabilityHelper = capabilityHelper + self.now = now + } + + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { + if path == DiagnosticsCapabilityProtocol.path { + guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } + return try makeCapabilityResponseForQuery(body, helperKey: capabilityHelper) + } + if path == DiagnosticsNamespaceProtocol.authorizationPath { + guard !responseBody else { throw DiagnosticsProtocolError.invalidMessage } + return nil + } + guard path == DiagnosticsPairingProtocol.path else { + throw DiagnosticsProtocolError.invalidMessage + } + let message = try DiagnosticsPairingProtocol.decode(body) + types.append(message.type) + switch message.type { + case .appKeyRotationRequest, .helperKeyRotationConfirm, .tlsPinRotationConfirm: + guard !responseBody else { throw DiagnosticsProtocolError.invalidMessage } + return nil + case .appKeyRotationNewProof: + guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } + return try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: message, + type: .appKeyRotationAccept, + transitionKind: nil, + transitionDigest: nil, + signer: currentHelper, + nonce: Data(repeating: 0x71, count: 32), + now: now + ).canonical + case .lifecycleFinalize: + guard responseBody, + let rawKind = message.value.unsigned(for: 29), + let kind = DiagnosticsPairingProtocol.TransitionKind(rawValue: rawKind), + let digest = message.value.bytes(for: 28, count: 32) else { + throw DiagnosticsProtocolError.invalidMessage + } + let signer: Curve25519.Signing.PrivateKey + if kind == .helperKey { + guard let proposedHelper else { throw DiagnosticsProtocolError.invalidMessage } + signer = proposedHelper + } else { + signer = currentHelper + } + return try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: message, + type: .lifecycleActiveAck, + transitionKind: kind, + transitionDigest: digest, + signer: signer, + nonce: Data(repeating: 0x72, count: 32), + now: now + ).canonical + case .revocationRequest: + guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } + return try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: message, + type: .revocationRecord, + transitionKind: nil, + transitionDigest: nil, + signer: currentHelper, + nonce: Data(repeating: 0x73, count: 32), + now: now + ).canonical + default: + throw DiagnosticsProtocolError.invalidMessage + } + } + + func observedTypes() -> [DiagnosticsPairingProtocol.MessageType] { types } +} + +private actor AbortLifecycleTransport: DiagnosticsTransporting { + private let currentHelper: Curve25519.Signing.PrivateKey + private let now: Date + private var failProofOnce = true + private var types: [DiagnosticsPairingProtocol.MessageType] = [] + + init(currentHelper: Curve25519.Signing.PrivateKey, now: Date) { + self.currentHelper = currentHelper + self.now = now + } + + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { + guard path == DiagnosticsPairingProtocol.path else { + throw DiagnosticsProtocolError.invalidMessage + } + let message = try DiagnosticsPairingProtocol.decode(body) + types.append(message.type) + switch message.type { + case .appKeyRotationRequest: + guard !responseBody else { throw DiagnosticsProtocolError.invalidMessage } + return nil + case .appKeyRotationNewProof: + guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } + if failProofOnce { + failProofOnce = false + throw DiagnosticsProtocolError.unavailable + } + return try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: message, + type: .appKeyRotationAccept, + transitionKind: nil, + transitionDigest: nil, + signer: currentHelper, + nonce: Data(repeating: 0x74, count: 32), + now: now + ).canonical + case .lifecycleAbort: + guard responseBody, + let rawKind = message.value.unsigned(for: 29), + let kind = DiagnosticsPairingProtocol.TransitionKind(rawValue: rawKind), + let digest = message.value.bytes(for: 28, count: 32) else { + throw DiagnosticsProtocolError.invalidMessage + } + return try DiagnosticsPairingProtocol.makeLifecycleContinuation( + prior: message, + type: .lifecycleAbortAck, + transitionKind: kind, + transitionDigest: digest, + signer: currentHelper, + nonce: Data(repeating: 0x75, count: 32), + now: now + ).canonical + default: + throw DiagnosticsProtocolError.invalidMessage + } + } + + func observedTypes() -> [DiagnosticsPairingProtocol.MessageType] { types } +} + +private func makeHelperBootstrapResponse( + prior: DiagnosticsPairingProtocol.Message, + responseType: DiagnosticsPairingProtocol.MessageType, + helperKey: Curve25519.Signing.PrivateKey, + issuedAt: UInt64 +) throws -> Data { + guard let priorFields = prior.value.fields else { throw DiagnosticsProtocolError.invalidMessage } + let copiedLabels: Set + if responseType == .helperAccept { + copiedLabels = [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 23, 24] + } else { + copiedLabels = [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 18, 19, 20, 22, 23, 24, 25] + } + var fields = priorFields.filter { copiedLabels.contains($0.label) } + fields.append(DiagnosticsCBORField(label: 4, value: .unsigned(responseType.rawValue))) + if responseType == .helperAccept { + fields.append(DiagnosticsCBORField(label: 22, value: .bytes(try prior.digest()))) + fields.append(DiagnosticsCBORField(label: 25, value: .bytes(Data(repeating: 0x25, count: 32)))) + } else { + fields.append(DiagnosticsCBORField(label: 15, value: .unsigned(issuedAt))) + fields.append(DiagnosticsCBORField( + label: 16, + value: .unsigned(issuedAt + DiagnosticsPairingProtocol.maximumLifetime) + )) + fields.append(DiagnosticsCBORField(label: 26, value: .bytes(try prior.digest()))) + } + let unsigned = DiagnosticsCBORValue.map(fields) + let body = try DiagnosticsDeterministicCBOR.encode(unsigned) + let domain = try pairingDomainForTest(responseType) + var input = Data(domain.utf8) + input.append(body) + let signature = try helperKey.signature(for: input) + guard case .map(var signedFields) = unsigned else { throw DiagnosticsProtocolError.invalidMessage } + signedFields.append(DiagnosticsCBORField(label: 255, value: .bytes(signature))) + let encoded = try DiagnosticsDeterministicCBOR.encode(.map(signedFields)) + _ = try DiagnosticsPairingProtocol.decode(encoded) + return encoded +} + +private func pairingDomainForTest(_ type: DiagnosticsPairingProtocol.MessageType) throws -> String { + let suffix: String + switch type { + case .helperAccept: suffix = "helper-accept" + case .finalizeAck: suffix = "pairing-finalize-ack" + case .readyAck: suffix = "pairing-ready-ack" + case .activeAck: suffix = "pairing-active-ack" + case .abortAck: suffix = "pairing-abort-ack" + default: throw DiagnosticsProtocolError.invalidMessage + } + return "eu.vaultsync.helper-pairing/v1/\(suffix)\0" +} + +private func makeNamespaceRoot( + enablement: Data, + record: DiagnosticsPairingRecord, + helperKey: Curve25519.Signing.PrivateKey, + readmeDigest: Data, + createdAt: UInt64 +) throws -> Data { + let enablementValue = try DiagnosticsDeterministicCBOR.decode(enablement) + guard let nonce = enablementValue.bytes(for: 19, count: 32) else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(DiagnosticsNamespaceProtocol.capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(2)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(Data(repeating: 0x07, count: 32))), + DiagnosticsCBORField(label: 13, value: .bytes(record.helperPublicKey)), + DiagnosticsCBORField(label: 14, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 15, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 19, value: .bytes(nonce)), + DiagnosticsCBORField(label: 20, value: .bytes(DiagnosticsNamespaceProtocol.recordDigest(enablement))), + DiagnosticsCBORField(label: 28, value: .unsigned(createdAt)), + DiagnosticsCBORField(label: 29, value: .bytes(readmeDigest)), + ]) + return try signNamespaceMessage( + body, + signatureLabel: 255, + domain: "eu.vaultsync.namespace/v1/root-manifest\0", + key: helperKey + ) +} + +private func countersignInitialAuthorization( + _ candidate: Data, + helperKey: Curve25519.Signing.PrivateKey +) throws -> Data { + let value = try DiagnosticsDeterministicCBOR.decode(candidate) + return try signNamespaceMessage( + value, + signatureLabel: 255, + domain: "eu.vaultsync.namespace/v1/authorization-initial-helper\0", + key: helperKey + ) +} + +private func signNamespaceMessage( + _ value: DiagnosticsCBORValue, + signatureLabel: UInt64, + domain: String, + key: Curve25519.Signing.PrivateKey +) throws -> Data { + let body = try DiagnosticsDeterministicCBOR.encode(value) + var input = Data(domain.utf8) + input.append(body) + let signature = try key.signature(for: input) + guard case .map(var fields) = value else { throw DiagnosticsProtocolError.invalidMessage } + fields.append(DiagnosticsCBORField(label: signatureLabel, value: .bytes(signature))) + return try DiagnosticsDeterministicCBOR.encode(.map(fields)) +} + +private func removingSignatures(_ encoded: Data, labels: Set) throws -> Data { + let value = try DiagnosticsDeterministicCBOR.decode(encoded) + return try DiagnosticsDeterministicCBOR.encode(value.removing(labels: labels)) +} + +private final class InMemoryDiagnosticsKeychain: DiagnosticsKeychainAccess, @unchecked Sendable { + private var items: [String: [String: Any]] = [:] + + func attributes(account: String) -> [String: Any]? { + items[account] + } + + func copyMatching(_ query: CFDictionary, result: inout AnyObject?) -> OSStatus { + let values = dictionary(query) + if let account = values[kSecAttrAccount as String] as? String { + guard let item = items[account] else { return errSecItemNotFound } + if values[kSecReturnAttributes as String] as? Bool == true { + result = item as NSDictionary + } else { + result = item[kSecValueData as String] as AnyObject? + } + return errSecSuccess + } + guard !items.isEmpty else { return errSecItemNotFound } + result = Array(items.values) as NSArray + return errSecSuccess + } + + func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus { + let identity = dictionary(query) + guard let account = identity[kSecAttrAccount as String] as? String, + var item = items[account] else { return errSecItemNotFound } + for (key, value) in dictionary(attributes) { + item[key] = value + } + items[account] = item + return errSecSuccess + } + + func add(_ attributes: CFDictionary) -> OSStatus { + let item = dictionary(attributes) + guard let account = item[kSecAttrAccount as String] as? String else { return errSecParam } + guard items[account] == nil else { return errSecDuplicateItem } + items[account] = item + return errSecSuccess + } + + func delete(_ query: CFDictionary) -> OSStatus { + let values = dictionary(query) + if let account = values[kSecAttrAccount as String] as? String { + return items.removeValue(forKey: account) == nil ? errSecItemNotFound : errSecSuccess + } + guard !items.isEmpty else { return errSecItemNotFound } + items.removeAll() + return errSecSuccess + } + + private func dictionary(_ value: CFDictionary) -> [String: Any] { + value as NSDictionary as? [String: Any] ?? [:] + } +} + +private func expectDiagnosticsError( + _ expected: DiagnosticsProtocolError, + _ operation: () throws -> Void +) { + do { + try operation() + Issue.record("Expected diagnostics error \(expected)") + } catch let error as DiagnosticsProtocolError { + #expect(error == expected) + } catch { + Issue.record("Unexpected error: \(error)") + } +} diff --git a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift index 2da0489..43a011c 100644 --- a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift +++ b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift @@ -230,7 +230,7 @@ struct DiagnosticsUploadM5Tests { } } - @Test("M5 remains test-only in Swift and records no forbidden operation values") + @Test("M5 transfer remains absent from Swift product code and records no forbidden operation values") func privacyAndProductBoundary() throws { let fixture = try M5UploadFixtureLoader.load() let testDirectory = URL(fileURLWithPath: "\(#filePath)").deletingLastPathComponent() @@ -243,8 +243,20 @@ struct DiagnosticsUploadM5Tests { while let url = enumerator?.nextObject() as? URL { guard url.pathExtension == "swift" else { continue } let body = try String(contentsOf: url, encoding: .utf8) - #expect(!body.contains(M5UploadMessage.capability)) - #expect(!body.contains("eu.vaultsync.roundtrip/v1/upload-attestation")) + if body.contains(M5UploadMessage.capability) { + #expect(url.lastPathComponent == "DiagnosticsCapabilityNamespaceProtocol.swift") + } + for transferDomain in [ + "eu.vaultsync.roundtrip/v1/operation-request", + "eu.vaultsync.roundtrip/v1/attestation-query", + "eu.vaultsync.roundtrip/v1/upload-attestation", + "eu.vaultsync.roundtrip/v1/response-authorization", + "eu.vaultsync.roundtrip/v1/response-artifact", + "eu.vaultsync.roundtrip/v1/cleanup-request", + "eu.vaultsync.roundtrip/v1/cleanup-ack", + ] { + #expect(!body.contains(transferDomain)) + } } let allowedSnapshot = "phase=completed upload=true download=false roundtrip=false cleanup=0" diff --git a/ios/project.yml b/ios/project.yml index 1eabb89..0c4c842 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -78,7 +78,7 @@ targets: - eu.vaultsync.app.sync-refresh - eu.vaultsync.app.sync-continued - eu.vaultsync.app.sync-processing - NSCameraUsageDescription: "VaultSync uses the camera to scan Syncthing device QR codes for easy setup." + NSCameraUsageDescription: "VaultSync uses the camera to scan setup and pairing QR codes you explicitly choose." # Without this, iOS 14+ Local Network privacy silently blocks direct # TCP/QUIC dials to peers on the same Wi-Fi, forcing every connection # through public relays — the slowest reconnect path after a cold start. diff --git a/notify/diagnostics_contract_model_test.go b/notify/diagnostics_contract_model_test.go index 59a109d..571070d 100644 --- a/notify/diagnostics_contract_model_test.go +++ b/notify/diagnostics_contract_model_test.go @@ -290,6 +290,8 @@ func TestDiagnosticsRuntimeCarrierIsExplicitAndCoreRemainsIsolated(t *testing.T) uploadProtocolCarrier := filepath.Join(repoRoot, "notify", "diagnostics_upload_protocol.go") responseProtocolCarrier := filepath.Join(repoRoot, "notify", "diagnostics_response_protocol.go") capabilityProtocolCarrier := filepath.Join(repoRoot, "notify", "diagnostics_capability_protocol.go") + appPairingProtocolCarrier := filepath.Join(repoRoot, "ios", "VaultSync", "Services", "DiagnosticsPairingProtocol.swift") + appCapabilityNamespaceCarrier := filepath.Join(repoRoot, "ios", "VaultSync", "Services", "DiagnosticsCapabilityNamespaceProtocol.swift") runtimeRoots := []string{ filepath.Join(repoRoot, "notify"), filepath.Join(repoRoot, "ios", "VaultSync"), @@ -312,16 +314,22 @@ func TestDiagnosticsRuntimeCarrierIsExplicitAndCoreRemainsIsolated(t *testing.T) } for name, capability := range fixture.Capabilities { allowed := path == dormantCapabilityCarrier || path == capabilityProtocolCarrier || - (name == "pairing" && path == pairingProtocolCarrier) || - (name == "namespace" && path == namespaceProtocolCarrier) + (name == "pairing" && (path == pairingProtocolCarrier || path == appPairingProtocolCarrier)) || + (name == "namespace" && (path == namespaceProtocolCarrier || path == appCapabilityNamespaceCarrier)) || + (name == "roundtrip" && path == appCapabilityNamespaceCarrier) if bytes.Contains(body, []byte(capability)) && !allowed { return fmt.Errorf("runtime file %s contains an unapproved diagnostics capability %s", path, name) } } for name, domain := range fixture.Domains { - allowed := (strings.HasPrefix(name, "pairing.") && path == pairingProtocolCarrier) || - (strings.HasPrefix(name, "namespace.") && path == namespaceProtocolCarrier) || - (strings.HasPrefix(name, "roundtrip.") && (path == uploadProtocolCarrier || path == responseProtocolCarrier || path == capabilityProtocolCarrier)) + allowed := (strings.HasPrefix(name, "pairing.") && + (path == pairingProtocolCarrier || path == appPairingProtocolCarrier)) || + (strings.HasPrefix(name, "namespace.") && + (path == namespaceProtocolCarrier || path == appCapabilityNamespaceCarrier)) || + (strings.HasPrefix(name, "roundtrip.") && + (path == uploadProtocolCarrier || path == responseProtocolCarrier || path == capabilityProtocolCarrier)) || + ((name == "roundtrip.capability_query" || name == "roundtrip.capability_response") && + path == appCapabilityNamespaceCarrier) if bytes.Contains(body, []byte(strings.TrimSuffix(domain, "\x00"))) && !allowed { return fmt.Errorf("runtime file %s contains an unapproved signature domain", path) }