148 lines
5.5 KiB
Swift
148 lines
5.5 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
|
|
enum XRPAlgorithm: String {
|
|
case ed25519
|
|
case secp256k1
|
|
}
|
|
|
|
/// Set by the app target so this file stays compilable outside iOS.
|
|
enum Secp256k1Backend {
|
|
static var derivePrivateKey: ((Data) -> Data?)?
|
|
static var publicKey: ((Data) -> Data?)?
|
|
static var sign: ((Data, Data) -> Data?)?
|
|
static var addScalar: ((Data, Data) -> Data?)?
|
|
}
|
|
|
|
struct XRPKeypair {
|
|
let algorithm: XRPAlgorithm
|
|
/// ed25519: 32-byte seed. secp256k1: 32-byte scalar.
|
|
let privateKey: [UInt8]
|
|
/// ed25519: 0xED + 32-byte key. secp256k1: 33-byte compressed point.
|
|
let publicKey: [UInt8]
|
|
|
|
var address: String { XRPKey.address(fromPublicKey: publicKey) }
|
|
|
|
var signingPublicKeyHex: String { Hex.encode(publicKey) }
|
|
}
|
|
|
|
enum XRPKey {
|
|
static let ed25519SeedVersion: [UInt8] = [0x01, 0xe1, 0x4b]
|
|
static let familySeedVersion: [UInt8] = [0x21]
|
|
static let accountIdVersion: [UInt8] = [0x00]
|
|
static let ed25519Prefix: UInt8 = 0xED
|
|
|
|
// MARK: - Seeds
|
|
|
|
static func generateSeed(algorithm: XRPAlgorithm = .ed25519) -> String {
|
|
var entropy = [UInt8](repeating: 0, count: 16)
|
|
for index in entropy.indices { entropy[index] = UInt8.random(in: 0...255) }
|
|
let version = algorithm == .ed25519 ? ed25519SeedVersion : familySeedVersion
|
|
return XRPBase58.checkEncode(version: version, payload: entropy)
|
|
}
|
|
|
|
static func decodeSeed(_ seed: String) throws -> (entropy: [UInt8], algorithm: XRPAlgorithm) {
|
|
let decoded = try XRPBase58.decode(seed.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
guard decoded.count > 4 else { throw XRPError.invalidSeed }
|
|
let body = Array(decoded[0..<(decoded.count - 4)])
|
|
let checksum = Array(decoded[(decoded.count - 4)...])
|
|
guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidSeed }
|
|
|
|
if body.count == ed25519SeedVersion.count + 16, Array(body.prefix(3)) == ed25519SeedVersion {
|
|
return (Array(body.dropFirst(3)), .ed25519)
|
|
}
|
|
if body.count == familySeedVersion.count + 16, body[0] == familySeedVersion[0] {
|
|
return (Array(body.dropFirst(1)), .secp256k1)
|
|
}
|
|
throw XRPError.unsupportedSeed
|
|
}
|
|
|
|
// MARK: - Keys
|
|
|
|
/// Recovery phrase for a new wallet: 12 BIP-39 words.
|
|
static func generateMnemonic() -> String {
|
|
BIP39.generateMnemonic()
|
|
}
|
|
|
|
/// Accepts either a 12 word BIP-39 recovery phrase or a legacy XRP family
|
|
/// seed (`sEd…` / `s…`).
|
|
static func keypair(fromSecret secret: String) throws -> XRPKeypair {
|
|
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if BIP39.isMnemonic(trimmed) {
|
|
return try keypair(fromMnemonic: trimmed)
|
|
}
|
|
return try keypair(fromSeed: trimmed)
|
|
}
|
|
|
|
/// Derives the account at `m/44'/144'/0'/0/0`, the path hardware and mobile
|
|
/// wallets use for XRP.
|
|
static func keypair(fromMnemonic mnemonic: String) throws -> XRPKeypair {
|
|
guard BIP39.entropy(from: mnemonic) != nil else {
|
|
throw XRPError.invalidSeed
|
|
}
|
|
let seed = BIP39.seed(from: mnemonic)
|
|
guard let master = BIP32.masterKey(fromSeed: seed),
|
|
let derived = BIP32.derive(BIP32.xrpPath, from: master) else {
|
|
throw XRPError.unsupportedSeed
|
|
}
|
|
return XRPKeypair(algorithm: .secp256k1,
|
|
privateKey: derived.privateKey,
|
|
publicKey: derived.publicKey)
|
|
}
|
|
|
|
static func keypair(fromSeed seed: String) throws -> XRPKeypair {
|
|
let (entropy, algorithm) = try decodeSeed(seed)
|
|
switch algorithm {
|
|
case .ed25519:
|
|
let raw = XRPHash.sha512Half(entropy)
|
|
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(raw))
|
|
let publicKey = [ed25519Prefix] + Array(privateKey.publicKey.rawRepresentation)
|
|
return XRPKeypair(algorithm: .ed25519, privateKey: raw, publicKey: publicKey)
|
|
case .secp256k1:
|
|
guard let derive = Secp256k1Backend.derivePrivateKey,
|
|
let toPublic = Secp256k1Backend.publicKey,
|
|
let privateKey = derive(Data(entropy)),
|
|
let publicKey = toPublic(privateKey) else {
|
|
throw XRPError.unsupportedSeed
|
|
}
|
|
return XRPKeypair(algorithm: .secp256k1,
|
|
privateKey: Array(privateKey),
|
|
publicKey: Array(publicKey))
|
|
}
|
|
}
|
|
|
|
static func address(fromPublicKey publicKey: [UInt8]) -> String {
|
|
let accountId = XRPHash.ripemd160(XRPHash.sha256(publicKey))
|
|
return XRPBase58.checkEncode(version: accountIdVersion, payload: accountId)
|
|
}
|
|
|
|
static func isValidAddress(_ address: String) -> Bool {
|
|
(try? XRPBase58.checkDecode(address, versionLength: 1)) != nil
|
|
}
|
|
|
|
static func accountId(fromAddress address: String) throws -> [UInt8] {
|
|
let payload = try XRPBase58.checkDecode(address, versionLength: 1)
|
|
guard payload.count == 20 else { throw XRPError.invalidAddress }
|
|
return payload
|
|
}
|
|
|
|
// MARK: - Signing
|
|
|
|
/// ed25519 returns a 64-byte signature; secp256k1 a DER-encoded one, matching
|
|
/// what the XRP Ledger expects for each signing scheme.
|
|
static func sign(message: [UInt8], keypair: XRPKeypair) throws -> [UInt8] {
|
|
switch keypair.algorithm {
|
|
case .ed25519:
|
|
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(keypair.privateKey))
|
|
let signature = try privateKey.signature(for: Data(message))
|
|
return Array(signature)
|
|
case .secp256k1:
|
|
let digest = XRPHash.sha512Half(message)
|
|
guard let signFn = Secp256k1Backend.sign,
|
|
let signature = signFn(Data(digest), Data(keypair.privateKey)) else {
|
|
throw XRPError.unsupportedSeed
|
|
}
|
|
return Array(signature)
|
|
}
|
|
}
|
|
} |