Skip to content

Commit 30e76c1

Browse files
author
Neo
committed
Morpheus M-STATE: read-only security-state layer + first deterministic signing-wall test
The foundation the Morpheus security ADVISOR reads from. It OBSERVES the deterministic walls; it cannot touch them. If all of Core/Morpheus were deleted, the walls behave byte-for-byte identically. - MorpheusSecurityState: a caseless enum of pure static reads returning IMMUTABLE value snapshots — posture() (on testnet, configured flags, gated-key enforcement value, MVP), wallet() (enclave/biometrics, hasLocalSigningKey, ownerKeyBiometricGated, guardians, cloud backup, app attest), and per-action reads (contacts membership, native-send- requires-Face-ID, threshold crossings vs the user's own SecurityPreferences). No setter, no toggle, no veto; mutable globals are copied by value so no live reference escapes. It never calls or feeds the server security preflight. - WalletCreation.hasCloudBackup(): a read-only, non-secret iCloud-Keychain presence probe (the private cloudBackupID isn't externally readable). - Identity-only cloud restore (a wallet record with an empty key tag) is reported honestly: hasLocalSigningKey=false and ownerKeyBiometricGated=nil, never a fabricated "ungated" false that would wrongly suggest a reset. - SigningWallTests: the first deterministic test of the testnet chain lock. It references ZERO Core/Morpheus symbols and proves mainnet (8453) signing fails closed with signingChainNotPermitted even with a valid key — the wall is in the signing primitive, not the advisor. No warning flows and no UI — those are later, separate surfaces (each held). Flags OFF, OBSERVE, testnet.
1 parent 39c328e commit 30e76c1

5 files changed

Lines changed: 433 additions & 0 deletions

File tree

Blockchain/Wallet/WalletCreation.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,16 @@ extension WalletCreation {
812812
guard status == errSecSuccess else { throw WalletCreationError.cloudBackupFailed }
813813
return item as? Data
814814
}
815+
816+
/// Read-only, non-secret presence probe: is a recovery backup present in iCloud
817+
/// Keychain? Probes the REAL synchronizable keychain item — not the in-memory
818+
/// `cloudBackupID`, which is only set after a backup performed THIS launch — so
819+
/// it reflects durable truth across relaunches. Performs no writes. Exposed for
820+
/// the read-only security-state layer (the private `cloudBackupID` isn't readable
821+
/// from outside this type).
822+
static func hasCloudBackup() -> Bool {
823+
((try? iCloudKeychainLoad(account: backupCiphertextAccount)) ?? nil) != nil
824+
}
815825
}
816826

817827
// MARK: - Supporting Types
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
}

MTRX.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@
9595
2C176F45450E56DE323F782F /* WalletCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A545017C5C5093567AD948E /* WalletCreation.swift */; };
9696
3901B8377FB62A46C064A01F /* Morpheus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33D6EE77613E7B2F1F141168 /* Morpheus.swift */; };
9797
C7E75EEAFCDB324F0F27D1F6 /* MorpheusInterventions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5B53CEB20A0AC6890C68DA /* MorpheusInterventions.swift */; };
98+
D5D5D5D5D5D5D5D5D5D5D5A1 /* MorpheusSecurityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D5D5D5D5D5D5D5D5D5D5A2 /* MorpheusSecurityState.swift */; };
99+
D5D5D5D5D5D5D5D5D5D5D5B1 /* MorpheusSecurityStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D5D5D5D5D5D5D5D5D5D5B2 /* MorpheusSecurityStateTests.swift */; };
100+
D5D5D5D5D5D5D5D5D5D5D5C1 /* SigningWallTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D5D5D5D5D5D5D5D5D5D5C2 /* SigningWallTests.swift */; };
98101
F13A5EE6A4A3AD19031B4898 /* MorpheusThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052B56409AB55F01FF2B3181 /* MorpheusThreshold.swift */; };
99102
9BA21B2C1A7CF41D5026FEE5 /* MorpheusTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AE8D98B7661071134F775C /* MorpheusTriggers.swift */; };
100103
35393524816D8C3E7B10D1D0 /* MorpheusVoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09EAFA22D01A5382487DC54 /* MorpheusVoice.swift */; };
@@ -454,6 +457,9 @@
454457
1A545017C5C5093567AD948E /* WalletCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletCreation.swift"; sourceTree = "<group>"; };
455458
33D6EE77613E7B2F1F141168 /* Morpheus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Morpheus.swift"; sourceTree = "<group>"; };
456459
1F5B53CEB20A0AC6890C68DA /* MorpheusInterventions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MorpheusInterventions.swift"; sourceTree = "<group>"; };
460+
D5D5D5D5D5D5D5D5D5D5D5A2 /* MorpheusSecurityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MorpheusSecurityState.swift"; sourceTree = "<group>"; };
461+
D5D5D5D5D5D5D5D5D5D5D5B2 /* MorpheusSecurityStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MorpheusSecurityStateTests.swift"; sourceTree = "<group>"; };
462+
D5D5D5D5D5D5D5D5D5D5D5C2 /* SigningWallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SigningWallTests.swift"; sourceTree = "<group>"; };
457463
052B56409AB55F01FF2B3181 /* MorpheusThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MorpheusThreshold.swift"; sourceTree = "<group>"; };
458464
F7AE8D98B7661071134F775C /* MorpheusTriggers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MorpheusTriggers.swift"; sourceTree = "<group>"; };
459465
D09EAFA22D01A5382487DC54 /* MorpheusVoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MorpheusVoice.swift"; sourceTree = "<group>"; };
@@ -1346,6 +1352,7 @@
13461352
052B56409AB55F01FF2B3181,
13471353
F7AE8D98B7661071134F775C,
13481354
D09EAFA22D01A5382487DC54,
1355+
D5D5D5D5D5D5D5D5D5D5D5A2,
13491356
);
13501357
path = "Morpheus";
13511358
name = "Morpheus";
@@ -1509,6 +1516,8 @@
15091516
43BC5281F0A7293A664FBF73,
15101517
5FCAACB696FF6B12A817D3C1,
15111518
79115443B6ADCE2298D66886,
1519+
D5D5D5D5D5D5D5D5D5D5D5B2,
1520+
D5D5D5D5D5D5D5D5D5D5D5C2,
15121521
);
15131522
path = "Unit";
15141523
name = "Unit";
@@ -1783,6 +1792,7 @@
17831792
buildActionMask = 2147483647;
17841793
files = (
17851794
BA2BE38A5FE71D6BF04B301C /* TrinityTools.swift in Sources */,
1795+
D5D5D5D5D5D5D5D5D5D5D5A1 /* MorpheusSecurityState.swift in Sources */,
17861796
72DA692B2BC4452FA776E3D2 /* GlassOrb.swift in Sources */,
17871797
C05327F172E04FF8804BF566 /* GameRunnerView.swift in Sources */,
17881798
BD5750C44F7A44FEBB61EEB1 /* AsteroidStormView.swift in Sources */,
@@ -2112,6 +2122,8 @@
21122122
buildActionMask = 2147483647;
21132123
files = (
21142124
FE57FACE0000000000000A0B /* WalletTests.swift in Sources */,
2125+
D5D5D5D5D5D5D5D5D5D5D5B1 /* MorpheusSecurityStateTests.swift in Sources */,
2126+
D5D5D5D5D5D5D5D5D5D5D5C1 /* SigningWallTests.swift in Sources */,
21152127
7A1B0C0D0E0F1020304050B1 /* BridgeSmokeTest.swift in Sources */,
21162128
7A1B0C0D0E0F1020304050B3 /* InferenceRouterTests.swift in Sources */,
21172129
7A1B0C0D0E0F1020304050B5 /* MTRXAPIClientTests.swift in Sources */,

0 commit comments

Comments
 (0)