363 lines
17 KiB
Swift
363 lines
17 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
/// On-device verification against vectors generated from the reference
|
|
/// implementation (xrpl.js + ripple-keypairs + ripple-binary-codec). Runs only
|
|
/// when the app is launched with `-koinsSelfTest`.
|
|
enum SelfTest {
|
|
private static let log = OSLog(subsystem: "com.koins.app", category: "selftest")
|
|
|
|
/// Writes to both stdout and the unified log so results are visible however
|
|
/// the app was launched.
|
|
private static var report: [String] = []
|
|
|
|
private static func emit(_ line: String) {
|
|
print(line)
|
|
os_log("%{public}@", log: log, type: .default, line)
|
|
report.append(line)
|
|
}
|
|
|
|
/// Writes the collected lines into the app container so the result can be
|
|
/// read back off the device (console output is not always reachable).
|
|
private static func flushReport() {
|
|
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
|
let url = documents.appendingPathComponent("koins-selftest.txt")
|
|
try? report.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
|
|
}
|
|
|
|
static func runIfRequested() {
|
|
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
|
|
emit("KOINS_SELFTEST begin")
|
|
run()
|
|
emit("KOINS_SELFTEST end")
|
|
}
|
|
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
|
|
runNetwork()
|
|
}
|
|
}
|
|
|
|
/// End-to-end check of the live network paths (XRP RPC + native Monero
|
|
/// wallet). Triggered with `-koinsSelfTestNetwork`; cleans up after itself.
|
|
static func runNetwork() {
|
|
// Detached so the test does not inherit the main actor: the UI thread must
|
|
// stay free, otherwise the launch-time call would deadlock.
|
|
Task.detached(priority: .userInitiated) {
|
|
emit("KOINS_NET begin")
|
|
|
|
// The test creates temporary wallets, so snapshot the real ones and put
|
|
// them back afterwards instead of wiping them.
|
|
let backup = (xrp: WalletStore.wallet(.xrp),
|
|
xmr: WalletStore.wallet(.xmr),
|
|
xrpSecret: WalletStore.secret(.xrp),
|
|
xmrSecret: WalletStore.secret(.xmr))
|
|
|
|
func restoreWallets() {
|
|
if let xrp = backup.xrp { WalletStore.saveWallet(xrp, chain: .xrp) } else { WalletStore.removeWallet(.xrp) }
|
|
if let secret = backup.xrpSecret { WalletStore.saveSecret(secret, chain: .xrp) }
|
|
if let xmr = backup.xmr { WalletStore.saveWallet(xmr, chain: .xmr) } else { WalletStore.removeWallet(.xmr) }
|
|
if let secret = backup.xmrSecret { WalletStore.saveSecret(secret, chain: .xmr) }
|
|
emit("KOINS_NET restored saved wallets")
|
|
}
|
|
|
|
do {
|
|
let (record, phrase) = try XRPWalletService.shared.createWallet(name: "koins-net-selftest")
|
|
emit("KOINS_NET xrp address \(record.address) phraseWords \(phrase.split(separator: " ").count)")
|
|
let balance = try await XRPWalletService.shared.balance(address: record.address)
|
|
emit("KOINS_NET xrp funded=\(balance.funded) balance=\(balance.total)")
|
|
let history = try await XRPWalletService.shared.transactions(address: record.address)
|
|
emit("KOINS_NET xrp transactions=\(history.count)")
|
|
|
|
// A known funded account, to exercise the funded-balance path.
|
|
for reference in ["rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"] {
|
|
let reference = try await XRPWalletService.shared.balance(address: reference)
|
|
emit("KOINS_NET xrp reference funded=\(reference.funded) balance=\(reference.total)")
|
|
let referenceHistory = try await XRPWalletService.shared.transactions(address: "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C")
|
|
emit("KOINS_NET xrp reference transactions=\(referenceHistory.count)")
|
|
}
|
|
} catch {
|
|
emit("KOINS_NET xrp FAIL \(error)")
|
|
}
|
|
|
|
do {
|
|
let created = try await MoneroService.shared.createWallet(name: "koins-net-selftest")
|
|
let words = created.mnemonic.split(separator: " ").count
|
|
emit("KOINS_NET xmr address \(created.address) mnemonicWords \(words)")
|
|
let started = try await MoneroService.shared.startSync()
|
|
emit("KOINS_NET xmr startedSync \(started)")
|
|
for attempt in 1...6 {
|
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
|
let status = try await MoneroService.shared.syncStatus()
|
|
emit("KOINS_NET xmr status \(attempt) height=\(status.height) daemon=\(status.daemonHeight) connected=\(status.connected) syncing=\(status.syncing)")
|
|
}
|
|
let balance = try await MoneroService.shared.balance()
|
|
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
|
|
|
|
let accounts = try await MoneroService.shared.accounts()
|
|
emit("KOINS_NET xmr accounts \(accounts.count) subaddresses \(accounts.first?.subaddresses.count ?? 0)")
|
|
let account = try await MoneroService.shared.createAccount(label: "Savings")
|
|
emit("KOINS_NET xmr new account index \(account.index) address \(account.address.prefix(12))")
|
|
let subaddress = try await MoneroService.shared.createSubaddress(accountIndex: 0, label: "From Alice")
|
|
emit("KOINS_NET xmr new subaddress index \(subaddress.index) address \(subaddress.address.prefix(12))")
|
|
let reloaded = try await MoneroService.shared.accounts()
|
|
emit("KOINS_NET xmr accounts after \(reloaded.count) subaddresses \(reloaded.first?.subaddresses.count ?? 0)")
|
|
|
|
await MoneroService.shared.stopSync()
|
|
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
|
|
} catch {
|
|
emit("KOINS_NET xmr FAIL \(error)")
|
|
}
|
|
|
|
restoreWallets()
|
|
emit("KOINS_NET end")
|
|
flushReport()
|
|
}
|
|
}
|
|
|
|
static func run() {
|
|
var failures = 0
|
|
|
|
func expect(_ name: String, _ actual: String, _ wanted: String) {
|
|
let ok = actual.lowercased() == wanted.lowercased()
|
|
if !ok { failures += 1 }
|
|
emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)")
|
|
}
|
|
|
|
func expectTrue(_ name: String, _ value: Bool) {
|
|
if !value { failures += 1 }
|
|
emit("KOINS_SELFTEST \(name): \(value ? "PASS" : "FAIL")")
|
|
}
|
|
|
|
expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))),
|
|
"8eb208f7e05d987a9b044a8e98c6b087f15a0bfc")
|
|
|
|
// ed25519 vector
|
|
let edSeed = "sEdSPx8CLbFUna3DJdvJ74aQPLdemqu"
|
|
let edAddress = "rEhh6f9rj5UUBhFzGGaxS5zYU2CCqKFXBC"
|
|
let edPublicKey = "EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F1734"
|
|
if let keypair = try? XRPKey.keypair(fromSeed: edSeed) {
|
|
expect("ed25519 pubkey", Hex.encode(keypair.publicKey), edPublicKey)
|
|
expect("ed25519 address", keypair.address, edAddress)
|
|
} else {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST ed25519: FAIL keypair threw")
|
|
}
|
|
|
|
// secp256k1 vector: exercises the OpenSSL-backed derivation on device.
|
|
let secpSeed = "spFbtYoPd7Hg7AD9XZn7CBwUKC5Qe"
|
|
let secpPublicKey = "0322DC9BB14903F71186BC46B0107D55A069D23CD97BBD205491A992EC68FA0ABE"
|
|
let secpAddress = "rpsRYc8DbXzfVN32w3hZjUtyuF1K89hu47"
|
|
do {
|
|
let keypair = try XRPKey.keypair(fromSeed: secpSeed)
|
|
expect("secp256k1 algorithm", keypair.algorithm.rawValue, "secp256k1")
|
|
expect("secp256k1 pubkey", Hex.encode(keypair.publicKey), secpPublicKey)
|
|
expect("secp256k1 address", keypair.address, secpAddress)
|
|
} catch {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST secp256k1: FAIL \(error)")
|
|
}
|
|
|
|
// Serialization + hashing reference vector.
|
|
let signingHex = "53545800120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F173481149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1"
|
|
let referenceSignature = "F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D"
|
|
let expectedBlob = "120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F17347440F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D81149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1"
|
|
let expectedHash = "F853B5391CD6317EA370830EEA3C047AC8D21E9FA00E4116D94C48E360B0434F"
|
|
|
|
var payment = XRPPayment(account: edAddress,
|
|
destination: "rNMovRR3WPbFLVaSbETCCR71XsqyxhJ9P6",
|
|
amountDrops: 1_500_000,
|
|
feeDrops: 12,
|
|
sequence: 12345,
|
|
lastLedgerSequence: 99000123,
|
|
destinationTag: 777,
|
|
signingPublicKey: Hex.decode(edPublicKey) ?? [],
|
|
signature: Hex.decode(referenceSignature))
|
|
if let payload = try? payment.signingPayload() {
|
|
expect("signing payload", Hex.encode(payload), signingHex)
|
|
} else {
|
|
failures += 1
|
|
}
|
|
if let blob = try? payment.txBlob() {
|
|
expect("tx blob", Hex.encode(blob), expectedBlob)
|
|
if let hash = try? payment.transactionHash() {
|
|
expect("tx hash", Hex.encode(hash), expectedHash)
|
|
} else {
|
|
failures += 1
|
|
}
|
|
} else {
|
|
failures += 1
|
|
}
|
|
|
|
// BIP-39 / BIP-32 (m/44'/144'/0'/0/0): the recovery phrase used for new XRP
|
|
// wallets. Vectors come from @scure/bip39 + @scure/bip32 + ripple-keypairs.
|
|
let zeroMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
expect("bip39 encode", BIP39.mnemonic(from: [UInt8](repeating: 0, count: 16)), zeroMnemonic)
|
|
expect("bip39 entropy", Hex.encode(BIP39.entropy(from: zeroMnemonic) ?? []), String(repeating: "00", count: 16))
|
|
expect("bip39 rejects bad checksum",
|
|
BIP39.entropy(from: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon") == nil ? "rejected" : "accepted",
|
|
"rejected")
|
|
|
|
// BIP-32 intermediate values, to isolate derivation problems.
|
|
if let master = BIP32.masterKey(fromSeed: BIP39.seed(from: zeroMnemonic)) {
|
|
expect("bip32 master key", Hex.encode(master.privateKey),
|
|
"1837c1be8e2995ec11cda2b066151be2cfb48adf9e47b151d46adab3a21cdf67")
|
|
expect("bip32 master chain code", Hex.encode(master.chainCode),
|
|
"7923408dadd3c7b56eed15567707ae5e5dca089de972e07f3b860450e2a3b70e")
|
|
let H = BIP32.hardenedBit
|
|
for (level, path, expected) in [
|
|
(1, [H | 44], "cd46049fb82a4fccbd967c5234e61152f4df87d01d1ef833420814c67ee4b7ea"),
|
|
(2, [H | 44, H | 144], "a12250f1f38fb41b62f0f1a4ec9d3555d24788843cb30e6d98a777fe540adc1d"),
|
|
(3, [H | 44, H | 144, H | 0], "54bac451906ecd52648a49126a2606c9177761f14d31dda079c0f768ac3501fd"),
|
|
(4, [H | 44, H | 144, H | 0, 0], "e582ce1724e93eebe3f38f1d42c01620b66d136946fec450dfe71b62e65de54f"),
|
|
(5, BIP32.xrpPath, "90802a50aa84efb6cdb225f17c27616ea94048c179142fecf03f4712a07ea7a4"),
|
|
] {
|
|
if let child = BIP32.derive(path, from: master) {
|
|
expect("bip32 level \(level)", Hex.encode(child.privateKey), expected)
|
|
} else {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
|
|
}
|
|
}
|
|
} else {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST bip32 master: FAIL")
|
|
}
|
|
|
|
let mnemonicCases: [(String, String, String)] = [
|
|
(zeroMnemonic,
|
|
"031d68bc1a142e6766b2bdfb006ccfe135ef2e0e2e94abb5cf5c9ab6104776fbae",
|
|
"rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3"),
|
|
("legal winner thank year wave sausage worth useful legal winner thank yellow",
|
|
"0283e0926eb0ab7bb8f5c0b5637d9e2526bdc398763af79cd904f013178961692d",
|
|
"rpaLPza5oxhJ7V8nFDwPm9F7kmZ16uc8do"),
|
|
]
|
|
for (position, vector) in mnemonicCases.enumerated() {
|
|
do {
|
|
let keypair = try XRPKey.keypair(fromMnemonic: vector.0)
|
|
expect("bip44 pubkey #\(position)", Hex.encode(keypair.publicKey), vector.1)
|
|
expect("bip44 address #\(position)", keypair.address, vector.2)
|
|
} catch {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST bip44 #\(position): FAIL \(error)")
|
|
}
|
|
}
|
|
|
|
// QR round trip: generate an image, read it back, parse the payment URI.
|
|
let xmrAddress = "46cRPeU77sBVGSVXPyCjvJUdC9AjqvETWb6giUGskdnWN8ZifindmrH7mcztKU52iKgvWXY4dyPujg9LYUY1Q7dvDiwHNCE"
|
|
let xrpAddress = "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"
|
|
|
|
func roundTrip(_ name: String, _ text: String, checks: [(String, String)]) {
|
|
guard let image = QRCodeImage.make(text) else {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST \(name): FAIL could not render QR image")
|
|
return
|
|
}
|
|
guard let decoded = QRCodeReader.decodeText(in: image) else {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST \(name): FAIL vision could not read the QR image (\(Int(image.size.width))px)")
|
|
return
|
|
}
|
|
guard let payload = QRCodeReader.parse(decoded) else {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST \(name): FAIL parse of '\(decoded)'")
|
|
return
|
|
}
|
|
for (label, expected) in checks {
|
|
let actual: String
|
|
switch label {
|
|
case "address": actual = payload.address
|
|
case "amount": actual = payload.amount ?? ""
|
|
case "tag": actual = payload.tag.map(String.init) ?? ""
|
|
case "paymentId": actual = payload.paymentId ?? ""
|
|
case "memo": actual = payload.memo ?? ""
|
|
default: actual = ""
|
|
}
|
|
expect("\(name) \(label)", actual, expected)
|
|
}
|
|
}
|
|
|
|
roundTrip("qr monero uri",
|
|
"monero:\(xmrAddress)?tx_amount=1.5&tx_payment_id=abc123&tx_description=Rent",
|
|
checks: [("address", xmrAddress), ("amount", "1.5"), ("paymentId", "abc123"), ("memo", "Rent")])
|
|
roundTrip("qr monero plain", xmrAddress, checks: [("address", xmrAddress)])
|
|
roundTrip("qr xrp uri",
|
|
"ripple:\(xrpAddress)?amount=12.5&dt=99",
|
|
checks: [("address", xrpAddress), ("amount", "12.5"), ("tag", "99")])
|
|
roundTrip("qr xrp plain", xrpAddress, checks: [("address", xrpAddress)])
|
|
|
|
expectTrue("qr monero detected", AddressFormat.isMonero(xmrAddress))
|
|
expectTrue("qr xrp detected", AddressFormat.isXRP(xrpAddress))
|
|
expectTrue("qr rejects junk", QRCodeReader.parse("hello world")?.address == "hello world")
|
|
expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress))
|
|
expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress))
|
|
|
|
// secp256k1 signing must produce a low-S DER signature.
|
|
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
|
let message = Hex.decode(signingHex),
|
|
let signature = try? XRPKey.sign(message: message, keypair: keypair) {
|
|
let isDer = signature.count > 8 && signature[0] == 0x30
|
|
expect("secp256k1 signature is DER", isDer ? "yes" : "no", "yes")
|
|
if let order = BigNumber.fromHex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"),
|
|
let half = order.dividedByTwo(),
|
|
let s = DER.signatureScalar(signature) {
|
|
expect("secp256k1 signature is low-S", s.lessThanOrEqual(half) ? "yes" : "no", "yes")
|
|
}
|
|
emit("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
|
|
} else {
|
|
failures += 1
|
|
emit("KOINS_SELFTEST secp256k1 sign: FAIL")
|
|
}
|
|
|
|
emit("KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")")
|
|
flushReport()
|
|
}
|
|
}
|
|
|
|
/// Minimal big-endian integer helper used only for the low-S check.
|
|
struct BigNumber {
|
|
let bytes: [UInt8]
|
|
|
|
static func fromHex(_ hex: String) -> BigNumber? {
|
|
Hex.decode(hex).map { BigNumber(bytes: $0) }
|
|
}
|
|
|
|
func dividedByTwo() -> BigNumber? {
|
|
var result: [UInt8] = []
|
|
var carry: UInt16 = 0
|
|
for byte in bytes {
|
|
let value = carry * 256 + UInt16(byte)
|
|
result.append(UInt8(value / 2))
|
|
carry = value % 2
|
|
}
|
|
return BigNumber(bytes: result)
|
|
}
|
|
|
|
func lessThanOrEqual(_ other: BigNumber) -> Bool {
|
|
let left = trimmed()
|
|
let right = other.trimmed()
|
|
if left.count != right.count { return left.count < right.count }
|
|
for (a, b) in zip(left, right) where a != b { return a < b }
|
|
return true
|
|
}
|
|
|
|
private func trimmed() -> [UInt8] {
|
|
var value = bytes
|
|
while value.first == 0 && value.count > 1 { value.removeFirst() }
|
|
return value
|
|
}
|
|
}
|
|
|
|
enum DER {
|
|
/// Extracts the `s` component from a DER-encoded ECDSA signature.
|
|
static func signatureScalar(_ der: [UInt8]) -> BigNumber? {
|
|
guard der.count > 8, der[0] == 0x30 else { return nil }
|
|
var index = 2
|
|
guard der[index] == 0x02 else { return nil }
|
|
let rLength = Int(der[index + 1])
|
|
index += 2 + rLength
|
|
guard index + 2 <= der.count, der[index] == 0x02 else { return nil }
|
|
let sLength = Int(der[index + 1])
|
|
let start = index + 2
|
|
guard start + sLength <= der.count else { return nil }
|
|
return BigNumber(bytes: Array(der[start..<(start + sLength)]))
|
|
}
|
|
} |