Skip to content

Digital Signature — TypeScript source

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

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

// Digital Signature - Ed25519 / ECDSA P-256 message signing and verification
// via the Web Crypto API. 100% client-side: keys, messages and signatures
// never leave the browser.
//
// 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). ECDSA signs the SHA-256 digest of the message.

export type SignAlgorithm = 'ed25519' | 'ecdsa-p256';

export interface KeyPair {
  /** Hex-encoded SPKI (SubjectPublicKeyInfo) public key. Safe to share. */
  publicKey: string;
  /** Hex-encoded PKCS8 private key. Keep it secret - it IS the identity. */
  privateKey: string;
}

const HEX = /^(?:[0-9a-fA-F]{2})+$/;

function subtle(): SubtleCrypto {
  const c = globalThis.crypto;
  if (!c?.subtle) {
    throw new Error('Web Crypto is not available in this browser context.');
  }
  return c.subtle;
}

function assertAlgorithm(algorithm: SignAlgorithm): void {
  if (algorithm !== 'ed25519' && algorithm !== 'ecdsa-p256') {
    throw new Error('Unknown algorithm. Use "ed25519" or "ecdsa-p256".');
  }
}

function label(algorithm: SignAlgorithm): string {
  return algorithm === 'ed25519' ? 'Ed25519' : 'ECDSA P-256';
}

/** KeyGen params object for the requested algorithm (Web Crypto shape). */
function keyGenParams(algorithm: SignAlgorithm): Algorithm {
  return algorithm === 'ed25519'
    ? { name: 'Ed25519' }
    : { name: 'ECDSA', namedCurve: 'P-256' };
}

/** Sign/verify params object (ECDSA must pin the hash it signs with). */
function signParams(algorithm: SignAlgorithm): Algorithm {
  return algorithm === 'ed25519' ? { name: 'Ed25519' } : { name: 'ECDSA', hash: 'SHA-256' };
}

function hexToBytes(hex: string, what: string): Uint8Array {
  if (!hex || !HEX.test(hex)) {
    throw new Error(`${what} must be a non-empty hex string (pairs of 0-9 / a-f digits).`);
  }
  const out = new Uint8Array(hex.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}

function toHex(bytes: Uint8Array): string {
  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}

/** Generate a fresh signing key pair, exported as hex SPKI / PKCS8 keys. */
export async function generateKeyPair(algorithm: SignAlgorithm): Promise<KeyPair> {
  assertAlgorithm(algorithm);
  const s = subtle(); // throws its own descriptive error when unavailable
  let pair: CryptoKeyPair;
  try {
    pair = (await s.generateKey(keyGenParams(algorithm), true, ['sign', 'verify'])) as CryptoKeyPair;
  } catch {
    throw new Error(
      algorithm === 'ed25519'
        ? 'Ed25519 is not supported by this browser. Switch to ECDSA P-256 or update your browser.'
        : 'ECDSA P-256 key generation failed.'
    );
  }
  const spki = new Uint8Array(await subtle().exportKey('spki', pair.publicKey));
  const pkcs8 = new Uint8Array(await subtle().exportKey('pkcs8', pair.privateKey));
  return { publicKey: toHex(spki), privateKey: toHex(pkcs8) };
}

async function importPrivateKey(privateKeyHex: string, algorithm: SignAlgorithm): Promise<CryptoKey> {
  const bytes = hexToBytes(privateKeyHex, 'Private key');
  try {
    return await subtle().importKey('pkcs8', bytes, keyGenParams(algorithm), false, ['sign']);
  } catch {
    throw new Error(`Invalid private key for ${label(algorithm)} (expected hex PKCS8).`);
  }
}

async function importPublicKey(publicKeyHex: string, algorithm: SignAlgorithm): Promise<CryptoKey> {
  const bytes = hexToBytes(publicKeyHex, 'Public key');
  try {
    return await subtle().importKey('spki', bytes, keyGenParams(algorithm), false, ['verify']);
  } catch {
    throw new Error(`Invalid public key for ${label(algorithm)} (expected hex SPKI).`);
  }
}

/** Sign `message` with a hex PKCS8 private key. Returns the hex signature. */
export async function signMessage(
  message: string,
  privateKeyHex: string,
  algorithm: SignAlgorithm
): Promise<string> {
  assertAlgorithm(algorithm);
  if (message === '') {
    throw new Error('Message must not be empty.');
  }
  const key = await importPrivateKey(privateKeyHex, algorithm);
  const signature = new Uint8Array(
    await subtle().sign(signParams(algorithm), key, new TextEncoder().encode(message))
  );
  return toHex(signature);
}

/**
 * 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).
 */
export async function verifySignature(
  message: string,
  signatureHex: string,
  publicKeyHex: string,
  algorithm: SignAlgorithm
): Promise<boolean> {
  assertAlgorithm(algorithm);
  const signature = hexToBytes(signatureHex, 'Signature');
  const key = await importPublicKey(publicKeyHex, algorithm);
  return subtle().verify(
    signParams(algorithm),
    key,
    signature,
    new TextEncoder().encode(message)
  );
}

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 →