Project

pqc_asn1

0.0
The project is in a healthy, maintained state
Minimal ASN.1/DER/PEM/Base64 codec implemented in C with no OpenSSL dependency. Designed for post-quantum key serialization (SPKI, PKCS#8) but usable for any scheme that needs DER TLV encoding, Base64 with PEM line wrapping, and PEM armor parsing. Used by the ml_dsa gem and reusable by ML-KEM, SLH-DSA, and other PQC implementations.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 5.0
~> 13.0
 Project Readme

pqc_asn1

Algorithm-agnostic ASN.1/DER/PEM/Base64 codec for post-quantum cryptography key serialization. Implemented in C with no OpenSSL dependency.

Handles SPKI (SubjectPublicKeyInfo, RFC 5480) and PKCS#8 / OneAsymmetricKey (RFC 5958) structures for ML-DSA, ML-KEM, SLH-DSA, and any future scheme that uses standard key-wrapping formats.

Installation

Add to your Gemfile:

gem "pqc_asn1"

Or install directly:

gem install pqc_asn1

The gem builds a native C extension — a C compiler is required.

Usage

Public key (SPKI)

require "pqc_asn1"

# Encode
der = PqcAsn1::DER.build_spki(PqcAsn1::OID::ML_DSA_44, public_key_bytes)
pem = PqcAsn1::PEM.encode(der, "PUBLIC KEY")

# Decode
info = PqcAsn1::DER.parse_spki(der)
info.algorithm  # => "ML_DSA_44"
info.key        # => binary String (public key bytes)
info.to_der     # => original DER bytes
info.to_pem     # => PEM string with "PUBLIC KEY" label

Private key (PKCS#8)

# build_pkcs8 returns a SecureBuffer — memory is locked and securely zeroed on GC
der = PqcAsn1::DER.build_pkcs8(PqcAsn1::OID::ML_DSA_44, secret_key_bytes)
pem = PqcAsn1::PEM.encode(der, "PRIVATE KEY")

# SecureBuffer convenience
der.to_pem              # => PEM string with "PRIVATE KEY" label

# Decode
info = PqcAsn1::DER.parse_pkcs8(der)
info.algorithm          # => "ML_DSA_44"

# Use block-scoped access — bytes are securely zeroed when the block exits
info.key.use { |bytes| sign(bytes) }

# Or wipe manually
info.key.wipe!

Key size validation

# Raises ArgumentError if the key length doesn't match the OID
der = PqcAsn1::DER.build_spki(PqcAsn1::OID::ML_DSA_44, pk, validate: true)
der = PqcAsn1::DER.build_pkcs8(PqcAsn1::OID::ML_DSA_44, sk, validate: true)

Auto-detection

# Detects SPKI vs PKCS#8 vs EncryptedPrivateKeyInfo automatically
info = PqcAsn1::DER.parse_auto(der_bytes)
info.format     # => :spki, :pkcs8, or :encrypted_pkcs8

# PEM decode with auto-detection (uses PEM label to select parser)
info = PqcAsn1::DER.parse_pem(pem_string)

PEM encoding / decoding

# Encode arbitrary bytes
pem = PqcAsn1::PEM.encode(bytes, "PUBLIC KEY")

# Decode — returns a DecodeResult
result = PqcAsn1::PEM.decode_auto(pem)
result.label    # => "PUBLIC KEY"
result.data     # => DER bytes

# Explicit destructuring via .to_a
data, label = PqcAsn1::PEM.decode_auto(pem).to_a

# Iterate multiple PEM blocks in a String or IO stream
PqcAsn1::PEM.decode_each(pem_string) { |r| process(r.data, r.label) }

# IO streaming — reads line-by-line without slurping the file
File.open("keys.pem") do |f|
  PqcAsn1::PEM.decode_each(f) { |r| process(r.data, r.label) }
end

# Without a block — returns an Enumerator
PqcAsn1::PEM.decode_each(pem_string).map(&:data)

Pattern matching (Ruby 2.7+)

case PqcAsn1::DER.parse_spki(der)
in { oid:, key: }
  store_key(oid.name, key)
end

OID utilities

# Dotted-decimal <-> DER TLV
der_tlv = PqcAsn1::OID.from_dotted("2.16.840.1.101.3.4.3.17")
PqcAsn1::OID.to_dotted(der_tlv)   # => "2.16.840.1.101.3.4.3.17"

# Name lookup
PqcAsn1::OID.name_for(der_tlv)    # => "ML_DSA_44"

# OID value objects
oid = PqcAsn1::OID.new("2.16.840.1.101.3.4.3.17")
oid.name    # => "ML_DSA_44"
oid.dotted  # => "2.16.840.1.101.3.4.3.17"

Registering custom OIDs at runtime

Registration is thread-safe (protected by a Mutex).

oid = PqcAsn1::OID.register(
  "1.3.9999.1",
  "MY_ALGO_128",
  key_sizes: { public: 1312, secret: 2528 }
)

# Look up by name or dotted string — no constant is defined automatically
PqcAsn1::OID["MY_ALGO_128"]                        # => OID value object
PqcAsn1::OID["1.3.9999.1"]                         # => same object
PqcAsn1::DER.build_spki(oid, pk, validate: true)   # key size validation works

# To define a constant, assign explicitly:
PqcAsn1::OID::MY_ALGO_128 = oid

SecureBuffer

# Build a PKCS#8 key — returns a SecureBuffer
der = PqcAsn1::DER.build_pkcs8(PqcAsn1::OID::ML_DSA_44, secret_key_bytes)

# Block-scoped access — bytes zeroed after the block
der.use { |bytes| sign(bytes) }

# Write DER bytes directly to an IO (minimises heap exposure)
File.open("key.der", "wb") { |f| der.write_to(f) }

# PEM-encode directly to an IO
File.open("key.pem", "w") { |f| der.to_pem_io(f) }

# Wipe returns self for chaining
der.wipe!.wiped?  # => true

# Generate random secure bytes from the platform CSPRNG
nonce = PqcAsn1::SecureBuffer.random(32)

# Extract a sub-range as a new SecureBuffer
slice = der.slice(0, 64)

Input size limits

# Default: 1 MiB — protects parse methods against oversized input
PqcAsn1::DER.max_input_size          # => 1048576

# Adjust or disable
PqcAsn1::DER.max_input_size = 4 << 20  # 4 MiB
PqcAsn1::DER.max_input_size = nil       # no limit

Error handling

begin
  PqcAsn1::DER.parse_spki(bad_bytes)
rescue PqcAsn1::DERError => e
  # DER structure is malformed
  puts e.category   # => :malformed_input
  puts e.code       # => :outer_sequence (fine-grained symbol)
  puts e.offset     # => byte position where error was detected
rescue PqcAsn1::PEMError => e
  # Base64 / PEM armor problem
rescue PqcAsn1::OIDError => e
  # Unrecognised or invalid OID
rescue PqcAsn1::ParseError => e
  # Catches both DERError and PEMError
rescue PqcAsn1::Error => e
  # Top-level catch-all
end

Error categories:

Category Meaning
:malformed_input DER TLV structure is wrong
:malformed_encoding Base64 or PEM armor is invalid
:validation Key size or OID mismatch
:system Memory allocation failure

OID constants

PqcAsn1::OID contains value objects for all current NIST PQC algorithms:

Constant Algorithm OID
ML_DSA_44 ML-DSA (FIPS 204) 2.16.840.1.101.3.4.3.17
ML_DSA_65 ML-DSA (FIPS 204) 2.16.840.1.101.3.4.3.18
ML_DSA_87 ML-DSA (FIPS 204) 2.16.840.1.101.3.4.3.19
ML_KEM_512 ML-KEM (FIPS 203) 2.16.840.1.101.3.4.4.1
ML_KEM_768 ML-KEM (FIPS 203) 2.16.840.1.101.3.4.4.2
ML_KEM_1024 ML-KEM (FIPS 203) 2.16.840.1.101.3.4.4.3
SLH_DSA_SHA2_128S SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.20
SLH_DSA_SHA2_128F SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.21
SLH_DSA_SHA2_192S SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.22
SLH_DSA_SHA2_192F SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.23
SLH_DSA_SHA2_256S SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.24
SLH_DSA_SHA2_256F SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.25
SLH_DSA_SHAKE_128S SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.26
SLH_DSA_SHAKE_128F SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.27
SLH_DSA_SHAKE_192S SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.28
SLH_DSA_SHAKE_192F SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.29
SLH_DSA_SHAKE_256S SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.30
SLH_DSA_SHAKE_256F SLH-DSA (FIPS 205) 2.16.840.1.101.3.4.3.31

Key size reference

PqcAsn1::DER::KEY_SIZES maps each OID to { public:, secret: } byte counts. Used automatically when validate: true is passed to build_spki / build_pkcs8.

Requirements

  • Ruby >= 2.7.2
  • A C compiler (the gem builds a native extension)
  • POSIX platform (Linux, macOS). Windows is not supported — SecureBuffer requires mmap/mprotect/mlock which are unavailable on native Windows

License

Licensed under either of

at your option. Contributions are dual-licensed under the same terms unless explicitly stated otherwise.