Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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: [
Expand Down
14 changes: 11 additions & 3 deletions Sources/Core/Crypto/Ed25519.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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-<HEX>).
/// - 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
}
Expand Down Expand Up @@ -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)
}
}


Expand Down
95 changes: 95 additions & 0 deletions Sources/Core/Crypto/PrivateKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,13 +29,98 @@ 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
}
}

}

/// 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
Expand Down
12 changes: 11 additions & 1 deletion Sources/Core/Crypto/Secp256k1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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-<HEX>).
/// - 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
}
Expand Down Expand Up @@ -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)
}
}


Expand Down
5 changes: 4 additions & 1 deletion Sources/Core/Hex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions Tests/UnitTests/Ed25519Test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}


Expand Down
66 changes: 66 additions & 0 deletions Tests/UnitTests/Secp256k1Test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading