diff --git a/Package.swift b/Package.swift index f087740..7121e09 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,10 @@ let package = Package( ], products: [ .library(name: "Aptos", targets: ["Aptos", "APIs", "Core", "BCS", "Transactions", "Utils", "Types"]), + .library(name: "Core", targets: ["Core", "Types", "BCS"]), + .library(name: "Types", targets: ["Types", "BCS"]), + .library(name: "Utils", targets: ["Utils"]), + .library(name: "Transactions", targets: ["Transactions", "Core", "BCS", "Types"]), .library(name: "BIP32", targets: ["BIP32"]), ], dependencies: [ @@ -22,7 +26,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-docc-plugin", "1.0.0" ..< "2.0.0"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.1.0"), .package(url: "https://github.com/Electric-Coin-Company/MnemonicSwift.git", from: "2.2.4"), - .package(url: "https://github.com/GigaBitcoin/secp256k1.swift.git", from: "0.17.0"), + .package(url: "https://github.com/GigaBitcoin/secp256k1.swift.git", exact: "0.17.0"), .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", from: "1.8.2") ], targets: [ diff --git a/Sources/Core/Crypto/Ed25519.swift b/Sources/Core/Crypto/Ed25519.swift index 00ac627..7ed2884 100644 --- a/Sources/Core/Crypto/Ed25519.swift +++ b/Sources/Core/Crypto/Ed25519.swift @@ -76,9 +76,10 @@ public struct Ed25519PrivateKey: PrivateKey { public private(set) var signingKey: Hex /// Initialize a private key from a HexInput. - /// - Parameter hexInput: a HexInput + /// Supports both legacy hex format and AIP-80 compliant format (ed25519-priv-). + /// - Parameter hexInput: a HexInput (hex string, AIP-80 string, or byte array) public init(_ hexInput: HexInput) throws { - let privateKeyHex = try Hex.fromHexInput(hexInput) + let privateKeyHex = try AIP80PrivateKey.parseHexInput(hexInput, type: .ed25519) if privateKeyHex.toUInt8Array().count != Ed25519PrivateKey.LENGTH { throw PrivateKeyError.invalidLength } @@ -139,10 +140,17 @@ public struct Ed25519PrivateKey: PrivateKey { public func toUInt8Array() -> [UInt8] { return signingKey.toUInt8Array() } - + public func toString() -> String { return signingKey.toString() } + + /// Format the private key as an AIP-80 compliant string. + /// [Read about AIP-80](https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md) + /// - Returns: An AIP-80 compliant string (e.g., "ed25519-priv-0x...") + public func toAIP80String() throws -> String { + return try AIP80PrivateKey.formatPrivateKey(signingKey, type: .ed25519) + } } diff --git a/Sources/Core/Crypto/PrivateKey.swift b/Sources/Core/Crypto/PrivateKey.swift index 90f96c0..48459f7 100644 --- a/Sources/Core/Crypto/PrivateKey.swift +++ b/Sources/Core/Crypto/PrivateKey.swift @@ -8,6 +8,16 @@ public enum PrivateKeyError: Error { case invalidDerivationPath(_ path: String) // Invalid BIP44 path ${path} case invalidBIP44Path(_ path: String) + // Invalid AIP-80 format + case invalidAIP80Format(String) +} + +/// Variants of private keys that can comply with the AIP-80 standard. +/// [Read about AIP-80](https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md) +public enum PrivateKeyVariant: String, Sendable { + case ed25519 = "ed25519" + case secp256k1 = "secp256k1" + case secp256r1 = "secp256r1" } extension PrivateKeyError: Equatable { @@ -19,6 +29,8 @@ extension PrivateKeyError: Equatable { return path1 == path2 case (.invalidBIP44Path(let path1), .invalidBIP44Path(let path2)): return path1 == path2 + case (.invalidAIP80Format(let msg1), .invalidAIP80Format(let msg2)): + return msg1 == msg2 default: return false } @@ -26,6 +38,89 @@ extension PrivateKeyError: Equatable { } +/// AIP-80 compliant private key utilities. +/// [Read about AIP-80](https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md) +public struct AIP80PrivateKey { + /// The AIP-80 compliant prefixes for each private key type. Append this to a private key's hex representation + /// to get an AIP-80 compliant string. + /// + /// [Read about AIP-80](https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md) + public static let prefixes: [PrivateKeyVariant: String] = [ + .ed25519: "ed25519-priv-", + .secp256k1: "secp256k1-priv-", + .secp256r1: "secp256r1-priv-" + ] + + /// Format a HexInput to an AIP-80 compliant string. + /// + /// [Read about AIP-80](https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md) + /// + /// - Parameters: + /// - privateKey: The HexString or [UInt8] format of the private key. + /// - type: The private key type + /// - Returns: An AIP-80 compliant string representation + public static func formatPrivateKey(_ privateKey: HexInput, type: PrivateKeyVariant) throws -> String { + guard let prefix = prefixes[type] else { + throw PrivateKeyError.invalidAIP80Format("Unknown private key type: \(type)") + } + + // Remove the prefix if it exists + var formattedPrivateKey = privateKey + if let strKey = formattedPrivateKey as? String, strKey.hasPrefix(prefix) { + // Extract the hex part after the prefix (split by "-" and get the last part) + let components = strKey.components(separatedBy: "-") + if components.count >= 3 { + formattedPrivateKey = components[2] + } + } + + let hex = try Hex.fromHexInput(formattedPrivateKey) + return "\(prefix)\(hex.toStringWithoutPrefix())" + } + + /// Parse a HexInput that may be a HexString, [UInt8], or an AIP-80 compliant string to a Hex instance. + /// + /// [Read about AIP-80](https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md) + /// + /// - Parameters: + /// - value: A HexString, [UInt8], or an AIP-80 compliant string. + /// - type: The private key type + /// - strict: If true, the value MUST be compliant with AIP-80. If false, non-compliant formats are allowed without warning. If nil (default), non-compliant formats are allowed but a warning is printed. + /// - Returns: A Hex instance containing the private key bytes + public static func parseHexInput(_ value: HexInput, type: PrivateKeyVariant, strict: Bool? = nil) throws -> Hex { + guard let prefix = prefixes[type] else { + throw PrivateKeyError.invalidAIP80Format("Unknown private key type: \(type)") + } + + if let strValue = value as? String { + if strict == true && !strValue.hasPrefix(prefix) { + // The value does not start with the AIP-80 prefix, and strict is true. + throw PrivateKeyError.invalidAIP80Format("Invalid HexString input while parsing private key. Must be AIP-80 compliant string.") + } + + if strValue.hasPrefix(prefix) { + // AIP-80 Compliant String input + let components = strValue.components(separatedBy: "-") + if components.count >= 3 { + return try Hex.fromHexString(components[2]) + } else { + throw PrivateKeyError.invalidAIP80Format("Invalid AIP-80 format") + } + } else { + // HexString input (not AIP-80 compliant) + // If strict is not explicitly false, show a warning + if strict != false { + print("[Aptos SDK] It is recommended that private keys are AIP-80 compliant (https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md). You can fix the private key by formatting it with `AIP80PrivateKey.formatPrivateKey(privateKey: HexInput, type: PrivateKeyVariant)`.") + } + return try Hex.fromHexInput(strValue) + } + } else { + // The value is a [UInt8] or Data + return try Hex.fromHexInput(value) + } + } +} + public protocol PrivateKey: Serializable, Deserializable, Equatable, Hashable, Sendable { init(_ hexInput: HexInput) throws func sign(message: HexInput) throws -> any Signature diff --git a/Sources/Core/Crypto/Secp256k1.swift b/Sources/Core/Crypto/Secp256k1.swift index c333e8b..2c047aa 100644 --- a/Sources/Core/Crypto/Secp256k1.swift +++ b/Sources/Core/Crypto/Secp256k1.swift @@ -45,8 +45,11 @@ public struct Secp256k1PrivateKey: PrivateKey { public static let LENGTH = 32 public private(set) var key: Hex + /// Initialize a private key from a HexInput. + /// Supports both legacy hex format and AIP-80 compliant format (secp256k1-priv-). + /// - Parameter hexInput: a HexInput (hex string, AIP-80 string, or byte array) public init(_ hexInput: HexInput) throws { - let hex = try Hex.fromHexInput(hexInput) + let hex = try AIP80PrivateKey.parseHexInput(hexInput, type: .secp256k1) if hex.toUInt8Array().count != Secp256k1PrivateKey.LENGTH { throw PrivateKeyError.invalidLength } @@ -92,6 +95,13 @@ public struct Secp256k1PrivateKey: PrivateKey { public func toString() -> String { return key.toString() } + + /// Format the private key as an AIP-80 compliant string. + /// [Read about AIP-80](https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-80.md) + /// - Returns: An AIP-80 compliant string (e.g., "secp256k1-priv-0x...") + public func toAIP80String() throws -> String { + return try AIP80PrivateKey.formatPrivateKey(key, type: .secp256k1) + } } diff --git a/Sources/Core/Hex.swift b/Sources/Core/Hex.swift index 6785d52..997fe63 100644 --- a/Sources/Core/Hex.swift +++ b/Sources/Core/Hex.swift @@ -30,7 +30,8 @@ extension String: HexInput {} extension Data: HexInput {} extension Array: HexInput where Element == UInt8 {} // 0x1234 -extension Int: HexInput {} +extension Int: HexInput {} +extension Hex: HexInput {} public struct Hex: Sendable { private let data: Data @@ -79,6 +80,8 @@ public struct Hex: Sendable { public static func fromHexInput(_ hexInput: HexInput) throws -> Hex { switch hexInput { + case let hex as Hex: + return hex case let str as String: return try Hex.fromHexString(str) case let data as Data: diff --git a/Tests/UnitTests/Ed25519Test.swift b/Tests/UnitTests/Ed25519Test.swift index fea51be..68cddc7 100644 --- a/Tests/UnitTests/Ed25519Test.swift +++ b/Tests/UnitTests/Ed25519Test.swift @@ -183,6 +183,95 @@ class Ed25519PrivateKeyTest: XCTestCase { let key = try Ed25519PrivateKey.fromDerivationPath(path: path, mnemonic: mnemonic) XCTAssertEqual(key.toString(), privateKey) } + + // MARK: - AIP-80 Tests + func testShouldFormatPrivateKeyToAIP80() throws { + let privateKey = try Ed25519PrivateKey(Ed25519.privateKey) + let aip80String = try privateKey.toAIP80String() + + // Verify the format is correct + XCTAssertTrue(aip80String.hasPrefix("ed25519-priv-")) + + // Extract the hex part and verify it matches + let components = aip80String.components(separatedBy: "-") + XCTAssertEqual(components.count, 3) + XCTAssertEqual(components[0], "ed25519") + XCTAssertEqual(components[1], "priv") + + // Verify the hex part matches the original key (without 0x prefix) + let expectedHex = privateKey.toString().replacingOccurrences(of: "0x", with: "") + XCTAssertEqual(components[2], expectedHex) + } + + func testShouldParseAIP80FormattedPrivateKey() throws { + let privateKey = try Ed25519PrivateKey(Ed25519.privateKey) + let aip80String = try privateKey.toAIP80String() + + // Create a new private key from the AIP-80 string + let parsedPrivateKey = try Ed25519PrivateKey(aip80String) + + // Verify they are equal + XCTAssertEqual(parsedPrivateKey.toString(), privateKey.toString()) + XCTAssertEqual(parsedPrivateKey.toUInt8Array(), privateKey.toUInt8Array()) + } + + func testShouldParseNonAIP80PrivateKeyWithWarning() throws { + // This should work but should print a warning (we can't test the warning in unit tests easily) + let privateKey = try Ed25519PrivateKey(Ed25519.privateKey) + XCTAssertEqual(privateKey.toString(), Ed25519.privateKey) + } + + func testShouldFormatAIP80StringToAIP80() throws { + // Formatting an already AIP-80 formatted string should return the same format + let privateKey = try Ed25519PrivateKey(Ed25519.privateKey) + let aip80String1 = try privateKey.toAIP80String() + + // Create a new private key from the AIP-80 string and format it again + let privateKey2 = try Ed25519PrivateKey(aip80String1) + let aip80String2 = try privateKey2.toAIP80String() + + // Both should be equal + XCTAssertEqual(aip80String1, aip80String2) + } + + func testShouldParseByteArrayToAIP80() throws { + let hexUint8Array: [UInt8] = [ + 197, 51, 140, 210, 81, 194, 45, 170, 140, 156, 156, 201, 79, 73, 140, 200, 165, 199, 225, 210, 231, 82, 135, + 165, 221, 169, 16, 150, 254, 100, 239, 165 + ] + + let privateKey = try Ed25519PrivateKey(hexUint8Array) + let aip80String = try privateKey.toAIP80String() + + // Verify the format is correct + XCTAssertTrue(aip80String.hasPrefix("ed25519-priv-")) + + // Parse it back and verify + let parsedPrivateKey = try Ed25519PrivateKey(aip80String) + XCTAssertEqual(parsedPrivateKey.toUInt8Array(), hexUint8Array) + } + + func testShouldSignAndVerifyWithAIP80FormattedKey() throws { + let privateKey = try Ed25519PrivateKey(Ed25519.privateKey) + let aip80String = try privateKey.toAIP80String() + + // Create a new private key from the AIP-80 string + let aip80PrivateKey = try Ed25519PrivateKey(aip80String) + + // Sign a message with both keys + let message = "test message" + let signature1 = try privateKey.sign(message: message) + let signature2 = try aip80PrivateKey.sign(message: message) + + // Both public keys should be equal + let publicKey1 = try privateKey.publicKey() as! Ed25519PublicKey + let publicKey2 = try aip80PrivateKey.publicKey() as! Ed25519PublicKey + XCTAssertEqual(publicKey1.toString(), publicKey2.toString()) + + // Both signatures should verify with both public keys + XCTAssertTrue(try publicKey1.verifySignature(message: message, signature: signature1)) + XCTAssertTrue(try publicKey2.verifySignature(message: message, signature: signature2)) + } } diff --git a/Tests/UnitTests/Secp256k1Test.swift b/Tests/UnitTests/Secp256k1Test.swift index 3061782..cfda105 100644 --- a/Tests/UnitTests/Secp256k1Test.swift +++ b/Tests/UnitTests/Secp256k1Test.swift @@ -131,6 +131,72 @@ class Secp256k1PrivateKeyTest: XCTestCase { let key = try Secp256k1PrivateKey.fromDerivationPath(path: path, mnemonic: mnemonic) XCTAssertEqual(key.toString(), privateKey) } + + // MARK: - AIP-80 Tests + func testShouldFormatPrivateKeyToAIP80() throws { + let privateKey = try Secp256k1PrivateKey(Secp256k1.privateKey) + let aip80String = try privateKey.toAIP80String() + + // Verify the format is correct + XCTAssertTrue(aip80String.hasPrefix("secp256k1-priv-")) + + // Extract the hex part and verify it matches + let components = aip80String.components(separatedBy: "-") + XCTAssertEqual(components.count, 3) + XCTAssertEqual(components[0], "secp256k1") + XCTAssertEqual(components[1], "priv") + + // Verify the hex part matches the original key (without 0x prefix) + let expectedHex = privateKey.toString().replacingOccurrences(of: "0x", with: "") + XCTAssertEqual(components[2], expectedHex) + } + + func testShouldParseAIP80FormattedPrivateKey() throws { + let privateKey = try Secp256k1PrivateKey(Secp256k1.privateKey) + let aip80String = try privateKey.toAIP80String() + + // Create a new private key from the AIP-80 string + let parsedPrivateKey = try Secp256k1PrivateKey(aip80String) + + // Verify they are equal + XCTAssertEqual(parsedPrivateKey.toString(), privateKey.toString()) + XCTAssertEqual(parsedPrivateKey.toUInt8Array(), privateKey.toUInt8Array()) + } + + func testShouldFormatAIP80StringToAIP80() throws { + // Formatting an already AIP-80 formatted string should return the same format + let privateKey = try Secp256k1PrivateKey(Secp256k1.privateKey) + let aip80String1 = try privateKey.toAIP80String() + + // Create a new private key from the AIP-80 string and format it again + let privateKey2 = try Secp256k1PrivateKey(aip80String1) + let aip80String2 = try privateKey2.toAIP80String() + + // Both should be equal + XCTAssertEqual(aip80String1, aip80String2) + } + + func testShouldSignAndVerifyWithAIP80FormattedKey() throws { + let privateKey = try Secp256k1PrivateKey(Secp256k1.privateKey) + let aip80String = try privateKey.toAIP80String() + + // Create a new private key from the AIP-80 string + let aip80PrivateKey = try Secp256k1PrivateKey(aip80String) + + // Sign a message with both keys + let message = "test message" + let signature1 = try privateKey.sign(message: message) + let signature2 = try aip80PrivateKey.sign(message: message) + + // Both public keys should be equal + let publicKey1 = try privateKey.publicKey() as! Secp256k1PublicKey + let publicKey2 = try aip80PrivateKey.publicKey() as! Secp256k1PublicKey + XCTAssertEqual(publicKey1.toString(), publicKey2.toString()) + + // Both signatures should verify with both public keys + XCTAssertTrue(try publicKey1.verifySignature(message: message, signature: signature1)) + XCTAssertTrue(try publicKey2.verifySignature(message: message, signature: signature2)) + } } class Secp256k1SignatureTest: XCTestCase {