Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,9 @@ ios/HathorMobile.xcodeproj/project.xcworkspace/

.yalc/
yalc.lock

# Compiled Rust framework — 38 MB binary, fetched / built separately,
# never tracked. Source bindings (hathor_ct_crypto.swift, the FFI .h
# / .modulemap, and the hand-written HathorCtCryptoModule.{m,swift})
# remain tracked alongside this entry.
ios/HathorCtCrypto/libhathor_ct_crypto.xcframework/
68 changes: 68 additions & 0 deletions ios/HathorCtCrypto/HathorCtCryptoModule.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(HathorCtCryptoModule, NSObject)

RCT_EXTERN_METHOD(createShieldedOutput:(double)value
recipientPubkey:(NSArray *)recipientPubkey
tokenUid:(NSArray *)tokenUid
fullyShielded:(BOOL)fullyShielded
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(decryptShieldedOutput:(NSArray *)recipientPrivkey
ephemeralPubkey:(NSArray *)ephemeralPubkey
commitment:(NSArray *)commitment
rangeProof:(NSArray *)rangeProof
tokenUid:(NSArray *)tokenUid
assetCommitment:(NSArray *)assetCommitment
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(deriveEcdhSharedSecret:(NSArray *)privkey
pubkey:(NSArray *)pubkey
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(deriveTag:(NSArray *)tokenUid
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(createAssetCommitment:(NSArray *)tag
blindingFactor:(NSArray *)blindingFactor
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(createSurjectionProof:(NSArray *)codomainTag
codomainBlindingFactor:(NSArray *)codomainBlindingFactor
domain:(NSArray *)domain
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(computeBalancingBlindingFactor:(NSArray *)otherBlindingFactors
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(createShieldedOutputWithBlinding:(double)value
recipientPubkey:(NSArray *)recipientPubkey
tokenUid:(NSArray *)tokenUid
fullyShielded:(BOOL)fullyShielded
blindingFactor:(NSArray *)blindingFactor
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(createShieldedOutputWithBothBlindings:(double)value
recipientPubkey:(NSArray *)recipientPubkey
tokenUid:(NSArray *)tokenUid
valueBlindingFactor:(NSArray *)valueBlindingFactor
assetBlindingFactor:(NSArray *)assetBlindingFactor
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(computeBalancingBlindingFactorFull:(double)value
generatorBlindingFactor:(NSArray *)generatorBlindingFactor
inputs:(NSArray *)inputs
otherOutputs:(NSArray *)otherOutputs
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)

@end
258 changes: 258 additions & 0 deletions ios/HathorCtCrypto/HathorCtCryptoModule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import Foundation
import React

@objc(HathorCtCryptoModule)
class HathorCtCryptoModule: NSObject {

@objc static func requiresMainQueueSetup() -> Bool { false }

private func toData(_ arr: [Any]) -> Data {
Data(arr.compactMap { ($0 as? NSNumber)?.uint8Value })
}
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

toData silently drops non-NSNumber bytes — cryptographic truncation with no error

compactMap { ($0 as? NSNumber)?.uint8Value } discards any element that isn't an NSNumber. For a 32-byte key where one element is unexpectedly not an NSNumber, the crypto call proceeds with a 31-byte buffer. The UniFFI/Rust layer will then produce a cryptographic error whose message won't indicate the real cause (wrong input length).

🛡️ Proposed fix — reject on type mismatch
-  private func toData(_ arr: [Any]) -> Data {
-    Data(arr.compactMap { ($0 as? NSNumber)?.uint8Value })
-  }
+  private func toData(_ arr: [Any]) throws -> Data {
+    let bytes: [UInt8] = try arr.map {
+      guard let n = $0 as? NSNumber else {
+        throw NSError(domain: "HathorCtCrypto", code: -1,
+                      userInfo: [NSLocalizedDescriptionKey: "Expected NSNumber in byte array, got \(type(of: $0))"])
+      }
+      return n.uint8Value
+    }
+    return Data(bytes)
+  }

All call sites already sit inside do/catch blocks that forward errors to reject(...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/HathorCtCrypto/HathorCtCryptoModule.swift` around lines 9 - 11, The
toData(_ arr: [Any]) -> Data function silently drops non-NSNumber elements
causing truncated byte buffers; change its signature to throws (toData(_ arr:
[Any]) throws -> Data) and validate each element instead of using compactMap:
iterate over arr, attempt to cast each item to NSNumber and extract .uint8Value,
and if any element fails the cast throw a descriptive NSError/Swift error (e.g.,
"invalid byte at index X: expected NSNumber"), so callers in their existing
do/catch will surface a clear error instead of producing truncated crypto
buffers.


private func toArray(_ data: Data) -> [NSNumber] {
data.map { NSNumber(value: $0) }
}

@objc func createShieldedOutput(
_ value: Double,
recipientPubkey: [Any],
tokenUid: [Any],
fullyShielded: Bool,
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let result = try createShieldedOutputUniffi(
value: UInt64(value),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
recipientPubkey: toData(recipientPubkey),
tokenUid: toData(tokenUid),
fullyShielded: fullyShielded
)
resolve([
"ephemeralPubkey": toArray(result.ephemeralPubkey),
"commitment": toArray(result.commitment),
"rangeProof": toArray(result.rangeProof),
"blindingFactor": toArray(result.blindingFactor),
"assetCommitment": result.assetCommitment.map { toArray($0) } as Any,
"assetBlindingFactor": result.assetBlindingFactor.map { toArray($0) } as Any,
])
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func decryptShieldedOutput(
_ recipientPrivkey: [Any],
ephemeralPubkey: [Any],
commitment: [Any],
rangeProof: [Any],
tokenUid: [Any],
assetCommitment: [Any],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
// JS callers pass [] (instead of null) for the absent parameter, since
// the RN bridge can't marshal JS null → NSArray. Treat empty arrays as
// nil before forwarding to UniFFI.
// - tokenUid is empty for FullShielded (recovered from rangeproof message).
// - assetCommitment is empty for AmountShielded (generator from tokenUid).
let tuid: Data? = tokenUid.isEmpty ? nil : toData(tokenUid)
let ac: Data? = assetCommitment.isEmpty ? nil : toData(assetCommitment)
let result = try decryptShieldedOutputUniffi(
recipientPrivkey: toData(recipientPrivkey),
ephemeralPubkey: toData(ephemeralPubkey),
commitment: toData(commitment),
rangeProof: toData(rangeProof),
tokenUid: tuid,
assetCommitment: ac
)
resolve([
"value": NSNumber(value: result.value),
"blindingFactor": toArray(result.blindingFactor),
"tokenUid": toArray(result.tokenUid),
"assetBlindingFactor": result.assetBlindingFactor.map { toArray($0) } as Any,
"outputType": result.outputType,
])
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func deriveEcdhSharedSecret(
_ privkey: [Any],
pubkey: [Any],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let result = try deriveEcdhSharedSecretUniffi(privkey: toData(privkey), pubkey: toData(pubkey))
resolve(toArray(result))
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func deriveTag(
_ tokenUid: [Any],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let result = try deriveTagUniffi(tokenUid: toData(tokenUid))
resolve(toArray(result))
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func createAssetCommitment(
_ tag: [Any],
blindingFactor: [Any],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let result = try createAssetCommitmentUniffi(tag: toData(tag), blindingFactor: toData(blindingFactor))
resolve(toArray(result))
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func createSurjectionProof(
_ codomainTag: [Any],
codomainBlindingFactor: [Any],
domain: [[String: Any]],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let domainEntries = domain.map { entry -> SurjectionDomainEntry in
SurjectionDomainEntry(
generator: self.toData(entry["generator"] as! [Any]),
tag: self.toData(entry["tag"] as! [Any]),
blindingFactor: self.toData(entry["blindingFactor"] as! [Any])
)
}
let result = try createSurjectionProofUniffi(
codomainTag: toData(codomainTag),
codomainBlindingFactor: toData(codomainBlindingFactor),
domain: domainEntries
)
resolve(toArray(result))
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}
Comment on lines +189 to +205
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Force-cast dictionary lookups crash the app instead of rejecting the promise

The as! [Any] and as! NSNumber force-casts in createSurjectionProof (Lines 134–136) and computeBalancingBlindingFactorFull (Lines 235–244) execute inside a map closure that runs synchronously in the do block. If a domain entry is missing a required key (e.g., "generator", "vbf", "gbf") or carries an unexpected type, the force-cast throws an Objective-C exception that bypasses Swift's do/catch and crashes the process.

🐛 Proposed fix — safe unwrapping with rejection
-    do {
-      let domainEntries = domain.map { entry -> SurjectionDomainEntry in
-        SurjectionDomainEntry(
-          generator: self.toData(entry["generator"] as! [Any]),
-          tag: self.toData(entry["tag"] as! [Any]),
-          blindingFactor: self.toData(entry["blindingFactor"] as! [Any])
-        )
-      }
+    do {
+      guard domain.allSatisfy({ $0["generator"] is [Any] && $0["tag"] is [Any] && $0["blindingFactor"] is [Any] }) else {
+        reject("INVALID_INPUT", "domain entries must contain generator, tag, and blindingFactor arrays", nil)
+        return
+      }
+      let domainEntries = domain.map { entry -> SurjectionDomainEntry in
+        SurjectionDomainEntry(
+          generator: self.toData(entry["generator"] as! [Any]),
+          tag: self.toData(entry["tag"] as! [Any]),
+          blindingFactor: self.toData(entry["blindingFactor"] as! [Any])
+        )
+      }

Apply the same pattern (pre-validate then cast) for the inEntries/outEntries in computeBalancingBlindingFactorFull.

Also applies to: 233-246

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/HathorCtCrypto/HathorCtCryptoModule.swift` around lines 132 - 148, The
code currently force-casts dictionary values inside the domain map which can
raise Obj-C exceptions and crash; update createSurjectionProof and
computeBalancingBlindingFactorFull to safely unwrap and validate required keys
before mapping: for each domain/inEntry/outEntry use guard/if-let to extract and
type-check "generator", "tag", "blindingFactor" (or "vbf"/"gbf") as the expected
arrays/NSNumbers, reject the promise (reject("CRYPTO_ERROR", "missing or invalid
field: <field>", nil) or similar) and return early when validation fails, then
convert with toData and construct SurjectionDomainEntry and call
createSurjectionProofUniffi only after validation succeeds so no force-casts
remain.


@objc func computeBalancingBlindingFactor(
_ otherBlindingFactors: [[Any]],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let bfs = otherBlindingFactors.map { toData($0) }
let result = try computeBalancingBlindingFactorUniffi(otherBlindingFactors: bfs)
resolve(toArray(result))
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func createShieldedOutputWithBlinding(
_ value: Double,
recipientPubkey: [Any],
tokenUid: [Any],
fullyShielded: Bool,
blindingFactor: [Any],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let result = try createShieldedOutputWithBlindingUniffi(
value: UInt64(value),
recipientPubkey: toData(recipientPubkey),
tokenUid: toData(tokenUid),
fullyShielded: fullyShielded,
blindingFactor: toData(blindingFactor)
)
resolve([
"ephemeralPubkey": toArray(result.ephemeralPubkey),
"commitment": toArray(result.commitment),
"rangeProof": toArray(result.rangeProof),
"blindingFactor": toArray(result.blindingFactor),
"assetCommitment": result.assetCommitment.map { toArray($0) } as Any,
"assetBlindingFactor": result.assetBlindingFactor.map { toArray($0) } as Any,
])
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func createShieldedOutputWithBothBlindings(
_ value: Double,
recipientPubkey: [Any],
tokenUid: [Any],
valueBlindingFactor: [Any],
assetBlindingFactor: [Any],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let result = try createShieldedOutputWithBothBlindingsUniffi(
value: UInt64(value),
recipientPubkey: toData(recipientPubkey),
tokenUid: toData(tokenUid),
valueBlindingFactor: toData(valueBlindingFactor),
assetBlindingFactor: toData(assetBlindingFactor)
)
resolve([
"ephemeralPubkey": toArray(result.ephemeralPubkey),
"commitment": toArray(result.commitment),
"rangeProof": toArray(result.rangeProof),
"blindingFactor": toArray(result.blindingFactor),
"assetCommitment": result.assetCommitment.map { toArray($0) } as Any,
"assetBlindingFactor": result.assetBlindingFactor.map { toArray($0) } as Any,
])
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}

@objc func computeBalancingBlindingFactorFull(
_ value: Double,
generatorBlindingFactor: [Any],
inputs: [[String: Any]],
otherOutputs: [[String: Any]],
resolve: RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
do {
let inEntries = inputs.map { entry -> BlindingEntry in
BlindingEntry(
value: (entry["value"] as! NSNumber).uint64Value,
vbf: self.toData(entry["vbf"] as! [Any]),
gbf: self.toData(entry["gbf"] as! [Any])
)
}
let outEntries = otherOutputs.map { entry -> BlindingEntry in
BlindingEntry(
value: (entry["value"] as! NSNumber).uint64Value,
vbf: self.toData(entry["vbf"] as! [Any]),
gbf: self.toData(entry["gbf"] as! [Any])
)
}
let result = try computeBalancingBlindingFactorFullUniffi(
value: UInt64(value),
generatorBlindingFactor: toData(generatorBlindingFactor),
inputs: inEntries,
otherOutputs: outEntries
)
resolve(toArray(result))
} catch {
reject("CRYPTO_ERROR", error.localizedDescription, error)
}
}
}
Loading
Loading