From 63b5d81af8812ea577ccc053cd9fc24582ed1ba4 Mon Sep 17 00:00:00 2001 From: Andrew Hariri Date: Thu, 23 Oct 2025 17:17:38 -0700 Subject: [PATCH 1/3] Fix secp256k1 dependency - pin to exact version 0.17.0 The secp256k1.swift library changed product names between versions: - v0.17.0 uses 'secp256k1' product name - v0.18+ uses 'libsecp256k1' product name This commit pins to v0.17.0 to match the API this SDK was built against. --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f087740..929123c 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,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: [ From a9aff102e104de211478f0d5bf02be9225518ef5 Mon Sep 17 00:00:00 2001 From: Andrew Hariri Date: Thu, 23 Oct 2025 17:39:50 -0700 Subject: [PATCH 2/3] Expose submodules as separate library products Export Core, Types, Utils, and Transactions as separate library products to allow consumers to import these modules independently. This fixes module resolution issues when using the SDK from external packages. --- Package.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Package.swift b/Package.swift index 929123c..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: [ From b95fd6925db828b42b19beb090333240e2964119 Mon Sep 17 00:00:00 2001 From: Andrew Hariri Date: Thu, 23 Oct 2025 19:11:16 -0700 Subject: [PATCH 3/3] Add AIP-80 support for private keys (format ed25519-priv-HEX_KEY) --- Sources/Core/Crypto/Ed25519.swift | 14 +++- Sources/Core/Crypto/PrivateKey.swift | 95 ++++++++++++++++++++++++++++ Sources/Core/Crypto/Secp256k1.swift | 12 +++- Sources/Core/Hex.swift | 5 +- Tests/UnitTests/Ed25519Test.swift | 89 ++++++++++++++++++++++++++ Tests/UnitTests/Secp256k1Test.swift | 66 +++++++++++++++++++ 6 files changed, 276 insertions(+), 5 deletions(-) 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 {