Skip to content

Instantly share code, notes, and snippets.

@franchb
Forked from dlubawy/age-to-pem.md
Created November 13, 2025 10:16
Show Gist options
  • Select an option

  • Save franchb/b3e7f3ee55f210177d4af3b64c7860fb to your computer and use it in GitHub Desktop.

Select an option

Save franchb/b3e7f3ee55f210177d4af3b64c7860fb to your computer and use it in GitHub Desktop.
Convert age identity to PEM with self-signed certificate

Age to PEM

Converts an age identity key to a PEM file with a self-signed X.509 certificate using a random RSA key.

Requirements

  • Requires YubiKey Manager installed to import the key/cert to your YubiKey
  • pip install bech32 cryptography

Usage

Existing Key

  1. python age-to-pem.py -f ./age-identity.txt
  2. ykman piv keys import SLOT ./X25519.pem
  3. ykman piv certificates import SLOT certificate.pem

New Key Generation

  1. age-keygen | python age-to-pem.py
  2. ykman piv keys import SLOT ./X25519.pem
  3. ykman piv certificates import SLOT certificate.pem
import argparse
import datetime
import fileinput
import getpass
import sys
from base64 import b32decode
import bech32
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, x25519
from cryptography.x509.oid import NameOID
BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
def self_sign(private_key, output="./certificate.pem"):
signing_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
subject = issuer = x509.Name(
[
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "age-plugin-yubikey"),
x509.NameAttribute(NameOID.COMMON_NAME, "YUBIKEY"),
]
)
custom_oid = x509.ObjectIdentifier("1.3.6.1.4.1.41482.3.8")
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
.not_valid_after(datetime.datetime.max)
.add_extension(
x509.SubjectAlternativeName([x509.DNSName("localhost")]),
critical=False,
)
.add_extension(
x509.UnrecognizedExtension(custom_oid, bytes([0x02, 0x02])),
critical=False,
)
.sign(signing_key, hashes.SHA256())
)
# Write our certificate out to disk.
with open(output, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
def main(filename=None, output=None, password=None):
(name, data) = (None, None)
if not filename or filename == "-":
for line in fileinput.input():
if line[0] != "#":
(name, data) = bech32.bech32_decode(line.rstrip())
break
else:
with open(filename, "r") as f:
for line in f.readlines():
if line[0] != "#":
(name, data) = bech32.bech32_decode(line.rstrip())
break
output = output if output else "./X25519.pem"
if (name, data) == (None, None):
print(f"Failed to parse bech32: {line}")
sys.exit(1)
b32str = "".join(BASE32_ALPHABET[num] for num in data)
b32str += "=" * (8 - (len(b32str) % 8))
decoded = b32decode(b32str)
private_key = x25519.X25519PrivateKey.from_private_bytes(decoded)
if not password:
password = getpass.getpass()
while password != getpass.getpass(prompt="Confirm password: "):
print("Try again.")
password = getpass.getpass().encode(encoding="utf-8")
with open(output, "wb") as f:
f.write(
private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(
password.encode(encoding="utf-8")
),
)
)
self_sign(private_key)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Parse age identity to PEM.")
parser.add_argument("-f", "--filename", help="age identity file")
parser.add_argument("-o", "--output", help="PEM output filename")
parser.add_argument(
"-p", "--password", help="Plaintext password to protect private key"
)
args = parser.parse_args()
main(args.filename, args.output, args.password)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment