Files
koins/apps/ios/Koins/SelfTest.swift
T

365 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))
emit("biometrics available=\(Biometrics.isAvailable) label=\(Biometrics.label)")
// 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)]))
}
}