Skip to content

Digital Signature — Swift source

Sign messages with Ed25519 or ECDSA and verify signatures. Prove authorship without revealing your private key.

This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// digital-signature — Ed25519 / ECDSA P-256 message signing and verification.
//
// Language: Swift 5.9+ (Foundation + CryptoKit)
// Ported from src/lib/digital-signature.ts
// display source — part of CosmoDev's polyglot tool pages
//
// Key encoding: keys are exchanged as hex of the standard DER structures -
// SPKI (SubjectPublicKeyInfo) for public keys, PKCS8 for private keys - so
// they interoperate with OpenSSL, SSH, JOSE tooling and the TS reference's
// Web Crypto keys. CryptoKit speaks raw key bytes, so this port carries the
// two fixed DER wrappers Web Crypto emits and unwraps them on import.
// Signatures are hex of the raw signature bytes: 64 bytes for both algorithms
// (Ed25519 R||S; ECDSA P-256 in IEEE P1363 r||s layout, NOT ASN.1 DER) —
// which is exactly CryptoKit's rawRepresentation. ECDSA signs the SHA-256
// digest of the message.

import Foundation
import CryptoKit

// MARK: - Types

enum SignAlgorithm: String, CaseIterable {
    case ed25519
    case ecdsaP256 = "ecdsa-p256"

    /// Human label used in error messages ("Ed25519" / "ECDSA P-256").
    var label: String { self == .ed25519 ? "Ed25519" : "ECDSA P-256" }
}

/// A signing key pair, both keys hex-encoded DER (KeyPair in the TS reference).
struct SigningKeyPair: Equatable {
    /// Hex-encoded SPKI (SubjectPublicKeyInfo) public key. Safe to share.
    let publicKey: String
    /// Hex-encoded PKCS8 private key. Keep it secret - it IS the identity.
    let privateKey: String
}

enum SignError: Error, LocalizedError {
    case unknownAlgorithm
    case unsupportedAlgorithm(String)
    case keyGenFailed(String)
    case emptyMessage
    case invalidHex(String)
    case invalidPrivateKey(String)
    case invalidPublicKey(String)

    var errorDescription: String? {
        switch self {
        case .unknownAlgorithm:
            return "Unknown algorithm. Use \"ed25519\" or \"ecdsa-p256\"."
        case .unsupportedAlgorithm(let a):
            return a
        case .keyGenFailed(let a):
            return a
        case .emptyMessage:
            return "Message must not be empty."
        case .invalidHex(let what):
            return "\(what) must be a non-empty hex string (pairs of 0-9 / a-f digits)."
        case .invalidPrivateKey(let a):
            return "Invalid private key for \(a) (expected hex PKCS8)."
        case .invalidPublicKey(let a):
            return "Invalid public key for \(a) (expected hex SPKI)."
        }
    }
}

// MARK: - Hex codec

func hexToBytes(_ hex: String, _ what: String) throws -> [UInt8] {
    let chars = Array(hex)
    guard !chars.isEmpty, chars.count % 2 == 0,
          chars.allSatisfy({ c in
              ("0"..."9").contains(c) || ("a"..."f").contains(c) || ("A"..."F").contains(c)
          })
    else { throw SignError.invalidHex(what) }
    var out = [UInt8]()
    out.reserveCapacity(chars.count / 2)
    var i = 0
    while i < chars.count {
        let byte = UInt32(String(chars[i]), radix: 16)! * 16 + UInt32(String(chars[i + 1]), radix: 16)!
        out.append(UInt8(byte))
        i += 2
    }
    return out
}

func toHex(_ bytes: [UInt8]) -> String {
    bytes.map { String(format: "%02x", $0) }.joined()
}

// MARK: - DER wrappers (the exact SPKI / PKCS8 prefixes Web Crypto emits)

let ED25519_SPKI_PREFIX: [UInt8] = [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]
let ED25519_PKCS8_PREFIX: [UInt8] = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]
let P256_SPKI_PREFIX: [UInt8] = [
    0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
    0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00]
let P256_PKCS8_PREFIX: [UInt8] = [
    0x30, 0x81, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
    0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01,
    0x07, 0x04, 0x30, 0x30, 0x02, 0x01, 0x01, 0x04, 0x20]

/// Strip `prefix` from `der`, throwing when it does not lead.
func unwrapDER(_ der: [UInt8], prefix: [UInt8]) -> [UInt8] {
    guard der.count >= prefix.count, Array(der.prefix(prefix.count)) == prefix else { return [] }
    return Array(der.dropFirst(prefix.count))
}

// MARK: - Key generation

/// Generate a fresh signing key pair, exported as hex SPKI / PKCS8 keys.
func generateKeyPair(algorithm: SignAlgorithm) throws -> SigningKeyPair {
    switch algorithm {
    case .ed25519:
        let key = Curve25519.Signing.PrivateKey()
        let spki = ED25519_SPKI_PREFIX + Array(key.publicKey.rawRepresentation)
        // Web Crypto's PKCS8 Ed25519 export ends with the private scalar only.
        let pkcs8 = ED25519_PKCS8_PREFIX + Array(key.rawRepresentation)
        return SigningKeyPair(publicKey: toHex(spki), privateKey: toHex(pkcs8))
    case .ecdsaP256:
        let key = P256.Signing.PrivateKey()
        // x963Representation of a P-256 public key is the uncompressed point
        // (0x04 || X || Y) — what the SPKI suffix carries.
        let spki = P256_SPKI_PREFIX + Array(key.publicKey.x963Representation)
        // ...and of the private key, the raw scalar.
        let pkcs8 = P256_PKCS8_PREFIX + Array(key.x963Representation)
        return SigningKeyPair(publicKey: toHex(spki), privateKey: toHex(pkcs8))
    }
}

// MARK: - Key import

func importEd25519PrivateKey(_ der: [UInt8]) throws -> Curve25519.Signing.PrivateKey {
    // The scalar follows the 04 20 (OCTET STRING, 32 bytes) marker.
    guard der.count == ED25519_PKCS8_PREFIX.count + 32,
          Array(der.prefix(ED25519_PKCS8_PREFIX.count)) == ED25519_PKCS8_PREFIX
    else { throw SignError.invalidPrivateKey("Ed25519") }
    return try Curve25519.Signing.PrivateKey(
        rawRepresentation: Data(der.dropFirst(ED25519_PKCS8_PREFIX.count)))
}

func importEd25519PublicKey(_ der: [UInt8]) throws -> Curve25519.Signing.PublicKey {
    let raw = unwrapDER(der, prefix: ED25519_SPKI_PREFIX)
    guard raw.count == 32 else { throw SignError.invalidPublicKey("Ed25519") }
    return try Curve25519.Signing.PublicKey(rawRepresentation: Data(raw))
}

func importP256PrivateKey(_ der: [UInt8]) throws -> P256.Signing.PrivateKey {
    let raw = unwrapDER(der, prefix: P256_PKCS8_PREFIX)
    guard raw.count >= 32 else { throw SignError.invalidPrivateKey("ECDSA P-256") }
    // The optional trailing [1] BIT STRING (public key) is ignored — the
    // scalar alone fully determines the key.
    return try P256.Signing.PrivateKey(rawRepresentation: Data(raw.prefix(32)))
}

func importP256PublicKey(_ der: [UInt8]) throws -> P256.Signing.PublicKey {
    let raw = unwrapDER(der, prefix: P256_SPKI_PREFIX)
    // The point is 0x04 || X || Y (65 bytes) — x963Representation.
    guard raw.count == 65 else { throw SignError.invalidPublicKey("ECDSA P-256") }
    return try P256.Signing.PublicKey(x963Representation: Data(raw))
}

// MARK: - Sign / verify

/// Sign `message` with a hex PKCS8 private key. Returns the hex signature.
func signMessage(_ message: String, privateKeyHex: String, algorithm: SignAlgorithm) throws -> String {
    if message.isEmpty { throw SignError.emptyMessage }
    let der = try hexToBytes(privateKeyHex, "Private key")
    let data = Data(message.utf8)
    switch algorithm {
    case .ed25519:
        let key = try importEd25519PrivateKey(der)
        // Ed25519 signs the message itself; the signature IS R||S (64 bytes).
        return toHex(Array(try key.signature(for: data)))
    case .ecdsaP256:
        let key = try importP256PrivateKey(der)
        // P256.Signing signs SHA-256 internally; rawRepresentation is the
        // IEEE P1363 r||s layout — the same as Web Crypto's output here.
        let sig = try key.signature(for: data)
        return toHex(Array(sig.rawRepresentation))
    }
}

/// Verify `signatureHex` against `message` with a hex SPKI public key.
/// Returns false when the signature, message or key simply do not match;
/// throws only for malformed input (non-hex / wrong key type).
func verifySignature(message: String, signatureHex: String, publicKeyHex: String,
                     algorithm: SignAlgorithm) throws -> Bool {
    let der = try hexToBytes(signatureHex, "Signature")
    let keyDer = try hexToBytes(publicKeyHex, "Public key")
    let data = Data(message.utf8)
    switch algorithm {
    case .ed25519:
        let pub = try importEd25519PublicKey(keyDer)
        return pub.isValidSignature(Data(der), for: data)
    case .ecdsaP256:
        let pub = try importP256PublicKey(keyDer)
        guard let sig = try? P256.Signing.ECDSASignature(rawRepresentation: Data(der)) else {
            return false
        }
        return pub.isValidSignature(sig, for: data)
    }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →