|
| 1 | +// MorpheusSecurityState.swift |
| 2 | +// MTRX — Morpheus security advisor: read-only security-state layer (M-STATE) |
| 3 | +// |
| 4 | +// The foundation the Morpheus ADVISOR surfaces read from. It OBSERVES the |
| 5 | +// deterministic security walls; it cannot touch them. Per MORPHEUS_ADVISOR_SPEC.md: |
| 6 | +// |
| 7 | +// • Morpheus advises, never enforces. NOTHING here returns a value consumed by a |
| 8 | +// signing or money-moving decision. If this file (and all of Core/Morpheus) |
| 9 | +// were deleted, the walls — testnet chain lock, biometric Secure-Enclave gate, |
| 10 | +// gated-key refusal, fail-closed send — behave byte-for-byte identically. |
| 11 | +// |
| 12 | +// • READ-ONLY. This is a caseless namespace of pure static reads that return |
| 13 | +// IMMUTABLE value snapshots. There is no stored state, no setter, no toggle, |
| 14 | +// and no reference to a live mutable singleton ever escapes — mutable globals |
| 15 | +// (e.g. SecureEnclaveManager.enforceGatedOwnerKeyForValue, SecurityPreferences) |
| 16 | +// are copied BY VALUE into the snapshot, never returned by reference. |
| 17 | +// |
| 18 | +// • "Morpheus" here is the CLIENT advisor only. This layer must NEVER import, |
| 19 | +// call, or feed the SERVER security preflight (MTRXAPIClient.securityPreflight- |
| 20 | +// AllowsSend / postFundMovingAttested / the securityBlocked|isSecurityBlock |
| 21 | +// error). Narrating "the security service declined this" must read the send's |
| 22 | +// already-computed failure state, never invoke the preflight. (Spec §6.) |
| 23 | +// |
| 24 | +// No warning flows and no UI live here — those are later, separate surfaces. |
| 25 | + |
| 26 | +import Foundation |
| 27 | +import LocalAuthentication |
| 28 | + |
| 29 | +@MainActor |
| 30 | +enum MorpheusSecurityState { |
| 31 | + |
| 32 | + // MARK: - §3a Global posture |
| 33 | + |
| 34 | + /// An immutable snapshot of the app's global security posture. Every field is a |
| 35 | + /// value copy of deterministic state; the struct holds no references. |
| 36 | + struct PostureSnapshot: Equatable { |
| 37 | + /// The configured target chain is the one (and only) chain signing is permitted on. |
| 38 | + let onTestnet: Bool |
| 39 | + /// The only chain the signing wall permits (compile-time constant, Base Sepolia). |
| 40 | + let permittedChainID: UInt64 |
| 41 | + /// The currently configured chain id. |
| 42 | + let configuredChainID: Int |
| 43 | + let chainConfigured: Bool |
| 44 | + let backendConfigured: Bool |
| 45 | + let gasSponsorshipConfigured: Bool |
| 46 | + let appAttestEnabled: Bool |
| 47 | + let appAttestEnforced: Bool |
| 48 | + /// Snapshot of the go-live gated-key enforcement flag's VALUE (false = OBSERVE). |
| 49 | + let gatedKeyEnforcementArmed: Bool |
| 50 | + /// Regulated finance features hidden for the App Store MVP build. |
| 51 | + let regulatedFeaturesHidden: Bool |
| 52 | + } |
| 53 | + |
| 54 | + static func posture() -> PostureSnapshot { |
| 55 | + let configured = PendingCredentials.Network.chainID |
| 56 | + let permitted = BaseNetworkConfig.permittedSigningChainID |
| 57 | + return PostureSnapshot( |
| 58 | + onTestnet: UInt64(configured) == permitted, |
| 59 | + permittedChainID: permitted, |
| 60 | + configuredChainID: configured, |
| 61 | + chainConfigured: PendingCredentials.isChainConfigured, |
| 62 | + backendConfigured: PendingCredentials.isBackendConfigured, |
| 63 | + gasSponsorshipConfigured: PendingCredentials.isGasSponsorshipConfigured, |
| 64 | + appAttestEnabled: PendingCredentials.isAppAttestEnabled, |
| 65 | + appAttestEnforced: PendingCredentials.isAppAttestEnforced, |
| 66 | + gatedKeyEnforcementArmed: SecureEnclaveManager.enforceGatedOwnerKeyForValue, |
| 67 | + regulatedFeaturesHidden: FeatureFlags.mvpMode |
| 68 | + ) |
| 69 | + } |
| 70 | + |
| 71 | + // MARK: - §3b Wallet / key security |
| 72 | + |
| 73 | + /// An immutable snapshot of the active wallet's protective state. |
| 74 | + struct WalletSecuritySnapshot: Equatable { |
| 75 | + let secureEnclaveAvailable: Bool |
| 76 | + let biometricsAvailable: Bool |
| 77 | + /// "Face ID" / "Touch ID" / "biometrics" — for honest narration. |
| 78 | + let biometryLabel: String |
| 79 | + let hasActiveWallet: Bool |
| 80 | + /// Whether there is a real LOCAL signing key on this device. false for an |
| 81 | + /// identity-only cloud restore (a wallet record exists but no local key yet — |
| 82 | + /// recovery is needed), which must NOT be narrated as "an ungated key". |
| 83 | + let hasLocalSigningKey: Bool |
| 84 | + /// Whether the owner key is biometric-gated. nil when there is no LOCAL signing |
| 85 | + /// key (no wallet, or an identity-only restore) — never a fabricated false. |
| 86 | + /// Probed from the REAL access control (non-spoofable). |
| 87 | + let ownerKeyBiometricGated: Bool? |
| 88 | + /// The user's own "require Face ID at signing" preference (value snapshot). |
| 89 | + let requireBiometricForSigning: Bool |
| 90 | + let guardianCount: Int |
| 91 | + let hasCloudBackup: Bool |
| 92 | + let appAttestSupported: Bool |
| 93 | + let appAttestRegistered: Bool |
| 94 | + } |
| 95 | + |
| 96 | + static func wallet() -> WalletSecuritySnapshot { |
| 97 | + let record = WalletRecordStore.load() |
| 98 | + let tag = record?.keyTag |
| 99 | + let biometry: String |
| 100 | + switch BiometricAuth.shared.biometryType { |
| 101 | + case .faceID: biometry = "Face ID" |
| 102 | + case .touchID: biometry = "Touch ID" |
| 103 | + default: biometry = "biometrics" |
| 104 | + } |
| 105 | + return WalletSecuritySnapshot( |
| 106 | + secureEnclaveAvailable: SecureEnclaveManager.shared.isSecureEnclaveAvailable, |
| 107 | + biometricsAvailable: BiometricAuth.shared.canUseBiometrics, |
| 108 | + biometryLabel: biometry, |
| 109 | + hasActiveWallet: record != nil, |
| 110 | + hasLocalSigningKey: hasLocalSigningKey(tag: tag), |
| 111 | + ownerKeyBiometricGated: ownerKeyGated(tag: tag), |
| 112 | + requireBiometricForSigning: SecurityPreferences.shared.requireBiometricForSigning, |
| 113 | + guardianCount: GuardianStore.load().count, |
| 114 | + hasCloudBackup: WalletCreation.hasCloudBackup(), |
| 115 | + appAttestSupported: AppAttestManager.shared.isSupported, |
| 116 | + appAttestRegistered: AppAttestManager.shared.hasKey |
| 117 | + ) |
| 118 | + } |
| 119 | + |
| 120 | + /// Whether the active wallet record points to a real LOCAL signing key (a |
| 121 | + /// non-empty key tag). An identity-only cloud restore persists a record with an |
| 122 | + /// EMPTY tag — address known, no local key yet, recovery needed — which must |
| 123 | + /// never be confused with "an ungated key" (that would wrongly suggest a reset). |
| 124 | + static func hasLocalSigningKey(tag: String?) -> Bool { (tag?.isEmpty == false) } |
| 125 | + |
| 126 | + /// Whether the owner key is biometric-gated. nil when there is no LOCAL signing |
| 127 | + /// key (no wallet, or an identity-only restore) — never a fabricated false. The |
| 128 | + /// probe reads the REAL access control (non-spoofable). |
| 129 | + static func ownerKeyGated(tag: String?) -> Bool? { |
| 130 | + guard let tag, !tag.isEmpty else { return nil } |
| 131 | + return SecureEnclaveManager.shared.isGated(tag: tag) |
| 132 | + } |
| 133 | + |
| 134 | + // MARK: - §3c Per-action context (only when an action is being composed) |
| 135 | + |
| 136 | + /// Whether the destination is one of the user's known contacts (membership in |
| 137 | + /// the contact address book). NOTE: this is the only recipient-novelty signal |
| 138 | + /// available today — there is no native-send history to ground "you've sent |
| 139 | + /// here before" (deferred in the spec), so callers must not overstate. |
| 140 | + static func isRecipientInContacts(_ address: String) -> Bool { |
| 141 | + isAddress(address, inKnown: ContactsManager.shared.mtrxContacts.map { $0.walletAddress }) |
| 142 | + } |
| 143 | + |
| 144 | + /// Pure, case-insensitive membership check — exposed so the predicate is testable |
| 145 | + /// without a live Contacts store. |
| 146 | + static func isAddress(_ address: String, inKnown known: [String]) -> Bool { |
| 147 | + let target = address.lowercased() |
| 148 | + return known.contains { $0.lowercased() == target } |
| 149 | + } |
| 150 | + |
| 151 | + /// Whether moving funds via the active wallet requires Face ID: true ONLY when a |
| 152 | + /// real local owner key exists and is biometric-gated (the enclave then requires |
| 153 | + /// Face ID to sign). false when there is no wallet OR an identity-only restore |
| 154 | + /// (no local key) OR a non-gated key — those are not "this send needs Face ID". |
| 155 | + static func nativeSendRequiresFaceID() -> Bool { |
| 156 | + ownerKeyGated(tag: WalletRecordStore.load()?.keyTag) == true |
| 157 | + } |
| 158 | + |
| 159 | + /// Which of the user's OWN configured thresholds a transfer crosses. The caller |
| 160 | + /// supplies the USD amount — which is PRICE-FEED-DERIVED (spec §5 price caveat), |
| 161 | + /// so any narration must say "at the current price" — and today's outgoing USD |
| 162 | + /// total. Pure comparison against SecurityPreferences VALUES; the singleton is |
| 163 | + /// never returned by reference. |
| 164 | + struct ThresholdCrossings: Equatable { |
| 165 | + let crossesExtraConfirm: Bool |
| 166 | + let crossesCoolingOff: Bool |
| 167 | + let coolingOffDelaySeconds: TimeInterval? |
| 168 | + let crossesDailySoft: Bool |
| 169 | + } |
| 170 | + |
| 171 | + static func thresholdCrossings(amountUSD: Double, todayOutgoingUSD: Double) -> ThresholdCrossings { |
| 172 | + let prefs = SecurityPreferences.shared |
| 173 | + let coolingOff = prefs.coolingOffDelay(amountUSD: amountUSD) |
| 174 | + return ThresholdCrossings( |
| 175 | + crossesExtraConfirm: prefs.requiresExtraConfirmation(amountUSD: amountUSD), |
| 176 | + crossesCoolingOff: coolingOff != nil, |
| 177 | + coolingOffDelaySeconds: coolingOff, |
| 178 | + crossesDailySoft: prefs.exceedsDailySoftThreshold(amountUSD: amountUSD, todayTotalUSD: todayOutgoingUSD) |
| 179 | + ) |
| 180 | + } |
| 181 | +} |
0 commit comments