Skip to content

Digital Signature — Ruby source

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

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

# Digital Signature — Ed25519 / ECDSA P-256 message signing and verification
# via OpenSSL.
#
# Language: Ruby (3.1+, standard library only)
# Source:   CosmoDev polyglot showcase port of the Digital Signature tool,
#           ported from src/lib/digital-signature.ts (the canonical
#           TypeScript implementation).
# License:  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 any other Web Crypto
# implementation. 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 - OpenSSL emits DER, so this port converts). ECDSA signs the
# SHA-256 digest of the message.

require 'openssl'

module DigitalSignature
  KeyPair = Struct.new(:public_key, :private_key, keyword_init: true)

  HEX_RE = /\A(?:[0-9a-fA-F]{2})+\z/

  class << self
    # Generate a fresh signing key pair, exported as hex SPKI / PKCS8 keys.
    # +algorithm+ is 'ed25519' or 'ecdsa-p256'.
    def generate_key_pair(algorithm)
      assert_algorithm(algorithm)
      pkey =
        if algorithm == 'ed25519'
          OpenSSL::PKey::Ed25519.generate
        else
          OpenSSL::PKey::EC.generate('prime256v1')
        end
      KeyPair.new(
        public_key: to_hex(pkey.public_to_der),
        private_key: to_hex(pkey.private_to_der)
      )
    end

    # Sign +message+ with a hex PKCS8 private key. Returns the hex signature.
    def sign_message(message, private_key_hex, algorithm)
      assert_algorithm(algorithm)
      raise ArgumentError, 'Message must not be empty.' if message.empty?

      key = import_private_key(private_key_hex, algorithm)
      signature =
        if algorithm == 'ed25519'
          # Ed25519 signs the message itself - no separate digest.
          key.sign(nil, message)
        else
          # ECDSA signs the SHA-256 digest; OpenSSL returns ASN.1 DER, the
          # shared format is IEEE P1363 r||s.
          der_to_p1363(key.sign(OpenSSL::Digest::SHA256.new, message))
        end
      to_hex(signature)
    end

    # Verify +signature_hex+ against +message+ with a hex SPKI public key.
    # Returns false when the signature, message or key simply do not match;
    # raises only for malformed input (non-hex / wrong key type).
    def verify_signature(message, signature_hex, public_key_hex, algorithm)
      assert_algorithm(algorithm)
      signature = hex_to_bytes(signature_hex, 'Signature')
      key = import_public_key(public_key_hex, algorithm)

      if algorithm == 'ed25519'
        key.public_key.verify(nil, signature, message)
      else
        der = p1363_to_der(signature)
        key.public_key.verify(OpenSSL::Digest::SHA256.new, der, message)
      end
    end

    private

    def assert_algorithm(algorithm)
      return if %w[ed25519 ecdsa-p256].include?(algorithm)

      raise ArgumentError, 'Unknown algorithm. Use "ed25519" or "ecdsa-p256".'
    end

    def label(algorithm)
      algorithm == 'ed25519' ? 'Ed25519' : 'ECDSA P-256'
    end

    def hex_to_bytes(hex, what)
      if hex.nil? || hex.empty? || !hex.match?(HEX_RE)
        raise ArgumentError,
              "#{what} must be a non-empty hex string (pairs of 0-9 / a-f digits)."
      end

      [hex].pack('H*')
    end

    def to_hex(bytes)
      bytes.unpack1('H*')
    end

    def import_private_key(private_key_hex, algorithm)
      bytes = hex_to_bytes(private_key_hex, 'Private key')
      OpenSSL::PKey.read(bytes)
    rescue OpenSSL::PKey::PKeyError
      raise ArgumentError,
            "Invalid private key for #{label(algorithm)} (expected hex PKCS8)."
    end

    def import_public_key(public_key_hex, algorithm)
      bytes = hex_to_bytes(public_key_hex, 'Public key')
      OpenSSL::PKey.read(bytes)
    rescue OpenSSL::PKey::PKeyError
      raise ArgumentError,
            "Invalid public key for #{label(algorithm)} (expected hex SPKI)."
    end

    # ASN.1 DER (SEQUENCE { INTEGER r, INTEGER s }) -> 64-byte IEEE P1363
    # r||s, each half left-padded with zeros to the field size.
    def der_to_p1363(der)
      seq = OpenSSL::ASN1.decode(der)
      r, s = seq.value.map { |int| int.value.to_i }
      hex = [r, s].map { |v| v.to_s(16).rjust(64, '0') }.join
      [hex].pack('H*')
    end

    # 64-byte IEEE P1363 r||s -> ASN.1 DER (for OpenSSL's verifier).
    def p1363_to_der(raw)
      raise ArgumentError, 'Signature must be 64 bytes (r||s).' if raw.bytesize != 64

      r, s = raw.unpack1('H*').scan(/.{64}/).map { |h| OpenSSL::BN.new(h, 16) }
      OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r),
                               OpenSSL::ASN1::Integer(s)]).to_der
    end
  end
end

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 →