Skip to content

Identity API

Misfin identity certificate generation, extraction, and fingerprint normalization.

MisfinIdentity

MisfinIdentity dataclass

MisfinIdentity(
    mailbox: str, hostname: str, blurb: str = ""
)

generate_identity_cert

generate_identity_cert

generate_identity_cert(
    mailbox: str,
    hostname: str,
    blurb: str = "",
    key_size: int = 2048,
    valid_days: int = 365,
) -> tuple[bytes, bytes]

Generate a Misfin identity certificate.

Uses cryptography directly (not tlacacoca's generate_self_signed_cert) because Misfin needs USER_ID for mailbox and CN for blurb, while tlacacoca puts hostname in CN.

Returns:

Type Description
tuple[bytes, bytes]

Tuple of (certificate_pem, private_key_pem) as bytes.

Source code in src/titlani/identity/certificate.py
def generate_identity_cert(
    mailbox: str,
    hostname: str,
    blurb: str = "",
    key_size: int = 2048,
    valid_days: int = 365,
) -> tuple[bytes, bytes]:
    """Generate a Misfin identity certificate.

    Uses cryptography directly (not tlacacoca's generate_self_signed_cert)
    because Misfin needs USER_ID for mailbox and CN for blurb, while
    tlacacoca puts hostname in CN.

    Returns:
        Tuple of (certificate_pem, private_key_pem) as bytes.
    """
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=key_size,
    )

    subject = issuer = x509.Name(
        [
            x509.NameAttribute(NameOID.USER_ID, mailbox),
            x509.NameAttribute(NameOID.COMMON_NAME, blurb or mailbox),
        ]
    )

    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.UTC))
        .not_valid_after(
            datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=valid_days)
        )
        .add_extension(
            x509.SubjectAlternativeName([x509.DNSName(hostname)]),
            critical=False,
        )
        .sign(private_key, hashes.SHA256())
    )

    cert_pem = cert.public_bytes(serialization.Encoding.PEM)
    key_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),
    )

    return cert_pem, key_pem

extract_identity

extract_identity

extract_identity(cert: Certificate) -> MisfinIdentity

Extract a Misfin identity from a certificate.

Source code in src/titlani/identity/certificate.py
def extract_identity(cert: x509.Certificate) -> MisfinIdentity:
    """Extract a Misfin identity from a certificate."""
    # Get mailbox from USER_ID
    mailbox = ""
    try:
        attrs = cert.subject.get_attributes_for_oid(NameOID.USER_ID)
        if attrs:
            mailbox = str(attrs[0].value)
    except (IndexError, AttributeError):
        pass

    # Get blurb from COMMON_NAME
    blurb = ""
    try:
        attrs = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
        if attrs:
            blurb = str(attrs[0].value)
    except (IndexError, AttributeError):
        pass

    # Get hostname from SAN DNS
    hostname = ""
    try:
        san_ext = cert.extensions.get_extension_for_oid(
            x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME
        )
        san = cast(x509.SubjectAlternativeName, san_ext.value)
        dns_names = san.get_values_for_type(x509.DNSName)
        if dns_names:
            hostname = dns_names[0]
    except x509.ExtensionNotFound:
        pass

    # Fallback for Gemini-style certs: CN=user@hostname with no USER_ID
    if not mailbox and blurb and "@" in blurb:
        mailbox, _, cn_host = blurb.partition("@")
        if not hostname:
            hostname = cn_host
        blurb = ""

    return MisfinIdentity(mailbox=mailbox, hostname=hostname, blurb=blurb)

generate_encryption_keypair

generate_encryption_keypair

generate_encryption_keypair() -> tuple[bytes, bytes]

Generate an X25519 keypair for at-rest mailbox encryption.

Returns:

Type Description
tuple[bytes, bytes]

Tuple of (public_key_pem, private_key_pem) as bytes.

Source code in src/titlani/identity/certificate.py
def generate_encryption_keypair() -> tuple[bytes, bytes]:
    """Generate an X25519 keypair for at-rest mailbox encryption.

    Returns:
        Tuple of (public_key_pem, private_key_pem) as bytes.
    """
    private_key = X25519PrivateKey.generate()

    public_pem = private_key.public_key().public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption(),
    )

    return public_pem, private_pem

normalize_fingerprint

normalize_fingerprint

normalize_fingerprint(fingerprint: str) -> str

Convert tlacacoca's 'sha256:hexdigest' format to Misfin(C) plain lowercase hex (no delimiters).

Strips known algorithm prefix, removes non-hex chars, lowercases.

Source code in src/titlani/identity/certificate.py
def normalize_fingerprint(fingerprint: str) -> str:
    """Convert tlacacoca's 'sha256:hexdigest' format to Misfin(C) plain
    lowercase hex (no delimiters).

    Strips known algorithm prefix, removes non-hex chars, lowercases.
    """
    lower = fingerprint.lower()
    for prefix in _ALGO_PREFIXES:
        if lower.startswith(prefix):
            fingerprint = fingerprint[len(prefix) :]
            break
    # Remove non-hex characters and lowercase
    return re.sub(r"[^0-9a-fA-F]", "", fingerprint).lower()