Skip to content

keypair

keypair

SSH keypair generation, import, and export against the credential DB.

The DB is the canonical home for SSH keys. This module moves material in and out of that form:

  • generate_keypair creates a fresh keypair in memory.
  • import_ssh_keypair reads an existing OpenSSH keypair from files and registers it against a scope.
  • export_ssh_keypair writes a scope's key back to an OpenSSH file pair for handing to tools that cannot use the SSH agent.

Internally, private keys live in the DB as unencrypted PKCS#8 DER — the single, opaque binary form the signer loads directly via load_der_private_key. Import converts any supported inbound PEM to that form at the boundary, and export re-armors it as OpenSSH PEM.

All flows share one vocabulary: the GeneratedKeypair dataclass is the portable in-memory form, and fingerprint_of defines the cross-call dedup key — the standard OpenSSH SHA256:<base64> fingerprint of the SSH wire-format public blob.

DEFAULT_RSA_BITS = 3072 module-attribute

Matches the ssh-keygen default as of OpenSSH 9.x.

PRIVATE_KEY_MODE = 384 module-attribute

PUBLIC_KEY_MODE = 420 module-attribute

GeneratedKeypair(key_type, private_der, public_blob, public_line, comment, fingerprint) dataclass

A keypair in the portable bytes form the vault stores.

private_der is unencrypted PKCS#8 DER — the raw-binary form we persist and feed straight to the signer. The public half stays in its usual SSH wire-format blob plus a pre-rendered public_line.

key_type instance-attribute

private_der instance-attribute

public_blob instance-attribute

public_line instance-attribute

comment instance-attribute

fingerprint instance-attribute

ImportResult(key_id, fingerprint, comment, already_present, scope_was_assigned) dataclass

Outcome of importing an OpenSSH keypair into the DB.

already_present reflects whether the key (by fingerprint) was already in the ssh_keys table. scope_was_assigned reflects whether the scope already owned a link to that key before this call. The two combine into four honest call outcomes: minted + linked, minted + re-linked (can't happen), re-used + linked (the common "multi-scope share" path), and re-used + no-op.

key_id instance-attribute

fingerprint instance-attribute

comment instance-attribute

already_present instance-attribute

scope_was_assigned instance-attribute

ExportResult(key_id, fingerprint, private_path, public_path) dataclass

Paths written by export_ssh_keypair.

key_id instance-attribute

fingerprint instance-attribute

private_path instance-attribute

public_path instance-attribute

InfraKeypair(scope, private_pem, public_line, fingerprint, key_type, created) dataclass

A keypair backing a sandbox-reserved %scope infrastructure slot.

Returned by ensure_infra_keypair. Carries the OpenSSH-PEM-serialised private (ready to ssh -i) and the matching public-key line (ready to bake into an authorized_keys file), so callers don't have to redo the DER→PEM conversion or re-derive the public line from raw blobs.

created distinguishes "minted fresh in this call" from "loaded from the existing assignment" — useful for surfacing first-use diagnostics without a second DB roundtrip.

scope instance-attribute

private_pem instance-attribute

public_line instance-attribute

fingerprint instance-attribute

key_type instance-attribute

created instance-attribute

PasswordProtectedKeyError

Bases: ValueError

Raised when an imported private key is encrypted with a passphrase.

KeypairMismatchError

Bases: ValueError

Raised when provided public and private keys disagree.

generate_keypair(key_type, *, comment)

Generate a fresh keypair entirely in memory.

Parameters:

Name Type Description Default
key_type str

"ed25519" or "rsa".

required
comment str

Comment to embed in the public line. Surfaces in ssh-add -L output and drives the signer's tk-main: promotion heuristic. Rejected with UnsafeCommentError if it contains control characters or exceeds the length limit.

required
Source code in src/terok_sandbox/vault/ssh/keypair.py
def generate_keypair(key_type: str, *, comment: str) -> GeneratedKeypair:
    """Generate a fresh keypair entirely in memory.

    Args:
        key_type: ``"ed25519"`` or ``"rsa"``.
        comment: Comment to embed in the public line.  Surfaces in
            ``ssh-add -L`` output and drives the signer's ``tk-main:``
            promotion heuristic.  Rejected with [`UnsafeCommentError`][terok_sandbox.vault.store.db.UnsafeCommentError]
            if it contains control characters or exceeds the length limit.
    """
    _require_safe_comment(comment)
    private_key: ed25519.Ed25519PrivateKey | rsa.RSAPrivateKey
    if key_type == "ed25519":
        private_key = ed25519.Ed25519PrivateKey.generate()
    elif key_type == "rsa":
        private_key = rsa.generate_private_key(public_exponent=65537, key_size=DEFAULT_RSA_BITS)
    else:
        raise ValueError(f"Unsupported key type: {key_type!r} (expected ed25519 or rsa)")

    private_der = _serialize_private_der(private_key)
    public_key = private_key.public_key()
    public_blob, public_line = _serialize_public(public_key, comment=comment)
    return GeneratedKeypair(
        key_type=key_type,
        private_der=private_der,
        public_blob=public_blob,
        public_line=public_line,
        comment=comment,
        fingerprint=fingerprint_of(public_blob),
    )

ensure_infra_keypair(scope, *, db, comment=None, key_type='ed25519')

Load or generate the %scope infrastructure keypair.

The single place sandbox-internal callers go for the load-or-mint dance:

  1. If scope already has an assigned key, re-serialise it as OpenSSH PEM + render the public line and return.
  2. Otherwise mint a fresh keypair, persist it under scope with assign_ssh_key(..., allow_infra=True), and return the same shape.

Only accepts %-prefixed scopes (the infrastructure form the DB-layer safe-scope validator recognises) — user scopes go through the normal ssh init / import_ssh_keypair paths.

The load-or-mint sequence runs inside a single db.transaction() so two concurrent callers can't both observe "empty" and both proceed to mint. Trust model: the returned private_pem is plaintext key material; possession of an unlocked CredentialDB is already operator-equivalent in this design, so callers with a DB handle can read any infra key. Callers MUST NOT log, serialise, or otherwise persist private_pem outside the intended consumer (e.g. ssh -i file or in-process signer). The keypair material is intended for sandbox-owned services that need a stable host-side identity (krun %host, future infrastructure slots); user-controlled code never goes through this helper.

Parameters:

Name Type Description Default
scope str

"%name" infrastructure scope. Validated structurally by the DB layer and refused here if it doesn't start with %.

required
db CredentialDB

Open CredentialDB — caller manages the lifetime.

required
comment str | None

Comment to embed in the public line on fresh generation. Ignored when the keypair already exists (existing comment is preserved). Defaults to "terok-infra:<scope>".

None
key_type str

"ed25519" (default) or "rsa".

'ed25519'

Returns:

Type Description
InfraKeypair
InfraKeypair

with the OpenSSH PEM private + public line.

Source code in src/terok_sandbox/vault/ssh/keypair.py
def ensure_infra_keypair(
    scope: str,
    *,
    db: CredentialDB,
    comment: str | None = None,
    key_type: str = "ed25519",
) -> InfraKeypair:
    """Load or generate the ``%scope`` infrastructure keypair.

    The single place sandbox-internal callers go for the load-or-mint
    dance:

    1. If *scope* already has an assigned key, re-serialise it as
       OpenSSH PEM + render the public line and return.
    2. Otherwise mint a fresh keypair, persist it under *scope* with
       ``assign_ssh_key(..., allow_infra=True)``, and return the same
       shape.

    Only accepts ``%``-prefixed scopes (the infrastructure form the
    DB-layer safe-scope validator recognises) — user scopes go through
    the normal ``ssh init`` / [`import_ssh_keypair`][terok_sandbox.vault.ssh.keypair.import_ssh_keypair]
    paths.

    The load-or-mint sequence runs inside a single
    [`db.transaction()`][terok_sandbox.vault.store.db.CredentialDB.transaction]
    so two concurrent callers can't both observe "empty" and both
    proceed to mint.  Trust model: the returned ``private_pem`` is
    plaintext key material; possession of an unlocked
    [`CredentialDB`][terok_sandbox.vault.store.db.CredentialDB] is
    already operator-equivalent in this design, so callers with a DB
    handle can read any infra key.  Callers MUST NOT log, serialise,
    or otherwise persist ``private_pem`` outside the intended
    consumer (e.g. ``ssh -i`` file or in-process signer).  The keypair material is intended
    for sandbox-owned services that need a stable host-side identity
    (krun ``%host``, future infrastructure slots); user-controlled
    code never goes through this helper.

    Args:
        scope: ``"%name"`` infrastructure scope.  Validated structurally
            by the DB layer and refused here if it doesn't start with
            ``%``.
        db: Open [`CredentialDB`][terok_sandbox.vault.store.db.CredentialDB]
            — caller manages the lifetime.
        comment: Comment to embed in the public line on fresh
            generation.  Ignored when the keypair already exists
            (existing comment is preserved).  Defaults to
            ``"terok-infra:<scope>"``.
        key_type: ``"ed25519"`` (default) or ``"rsa"``.

    Returns:
        An [`InfraKeypair`][terok_sandbox.vault.ssh.keypair.InfraKeypair]
        with the OpenSSH PEM private + public line.
    """
    if not scope.startswith("%"):
        raise ValueError(
            f"ensure_infra_keypair: scope {scope!r} must start with '%' "
            "(infrastructure-reserved form); user scopes use ssh init or "
            "import_ssh_keypair instead"
        )

    # Wrap the entire check-mint-assign in a single SQLite transaction
    # so two concurrent callers can't both observe "empty" and both
    # proceed to mint a separate key for the same scope.  The
    # re-check inside the transaction is what closes the race window.
    with db.transaction():
        existing = db.load_ssh_keys_for_scope(scope)
        if existing:
            # ``load_ssh_keys_for_scope`` orders by ``assigned_at``
            # ascending, so the last element is the most recently
            # assigned key.  Prefer it: if an additive rotation ever
            # leaves more than one key under the scope, returning the
            # oldest would silently resurrect the rotated-out material.
            record = existing[-1]
            return InfraKeypair(
                scope=scope,
                private_pem=openssh_pem_of(record.private_der),
                public_line=public_line_of(record),
                fingerprint=record.fingerprint,
                key_type=record.key_type,
                created=False,
            )

        keypair = generate_keypair(
            key_type,
            comment=comment if comment is not None else f"terok-infra:{scope}",
        )
        key_id = db.store_ssh_key(
            key_type=keypair.key_type,
            private_der=keypair.private_der,
            public_blob=keypair.public_blob,
            comment=keypair.comment,
            fingerprint=keypair.fingerprint,
        )
        db.assign_ssh_key(scope, key_id, allow_infra=True)
        return InfraKeypair(
            scope=scope,
            private_pem=openssh_pem_of(keypair.private_der),
            public_line=keypair.public_line,
            fingerprint=keypair.fingerprint,
            key_type=keypair.key_type,
            created=True,
        )

import_ssh_keypair(db, scope, priv_path, pub_path=None, comment=None)

Read a keypair from OpenSSH files and assign it to scope.

The public key is optional; when omitted it is derived from the private key. When both are given they must match — fingerprint mismatch raises KeypairMismatchError. Password-protected private keys raise PasswordProtectedKeyError; the library stays diagnostic-only, so callers own the remediation hint they render to the user.

Source code in src/terok_sandbox/vault/ssh/keypair.py
def import_ssh_keypair(
    db: CredentialDB,
    scope: str,
    priv_path: Path,
    pub_path: Path | None = None,
    comment: str | None = None,
) -> ImportResult:
    """Read a keypair from OpenSSH files and assign it to *scope*.

    The public key is optional; when omitted it is derived from the private
    key.  When both are given they must match — fingerprint mismatch raises
    [`KeypairMismatchError`][terok_sandbox.vault.ssh.keypair.KeypairMismatchError].  Password-protected private keys raise
    [`PasswordProtectedKeyError`][terok_sandbox.vault.ssh.keypair.PasswordProtectedKeyError]; the library stays diagnostic-only,
    so callers own the remediation hint they render to the user.
    """
    priv_bytes = priv_path.read_bytes()
    pub_bytes = pub_path.read_bytes() if pub_path else None
    parsed = parse_openssh_keypair(priv_bytes, pub_bytes, comment_override=comment)

    existing_row = db.get_ssh_key_by_fingerprint(parsed.fingerprint)
    already = existing_row is not None
    scope_was_assigned = existing_row is not None and any(
        r.id == existing_row.id for r in db.list_ssh_keys_for_scope(scope)
    )

    key_id = db.store_ssh_key(
        key_type=parsed.key_type,
        private_der=parsed.private_der,
        public_blob=parsed.public_blob,
        comment=parsed.comment,
        fingerprint=parsed.fingerprint,
    )
    db.assign_ssh_key(scope, key_id)
    return ImportResult(
        key_id=key_id,
        fingerprint=parsed.fingerprint,
        comment=parsed.comment,
        already_present=already,
        scope_was_assigned=scope_was_assigned,
    )

parse_openssh_keypair(priv_bytes, pub_bytes=None, *, comment_override=None)

Parse raw OpenSSH bytes into the canonical GeneratedKeypair form.

Passphrase-protected keys raise PasswordProtectedKeyError. Cryptography signals that condition with either TypeError ("Password was not given but private key is encrypted" on older releases) or ValueError (newer releases), depending on version — we catch both and only translate when the message mentions encryption/password. Malformed non-protected PEMs keep bubbling up as plain ValueError.

Source code in src/terok_sandbox/vault/ssh/keypair.py
def parse_openssh_keypair(
    priv_bytes: bytes,
    pub_bytes: bytes | None = None,
    *,
    comment_override: str | None = None,
) -> GeneratedKeypair:
    """Parse raw OpenSSH bytes into the canonical [`GeneratedKeypair`][terok_sandbox.vault.ssh.keypair.GeneratedKeypair] form.

    Passphrase-protected keys raise [`PasswordProtectedKeyError`][terok_sandbox.vault.ssh.keypair.PasswordProtectedKeyError].
    Cryptography signals that condition with either ``TypeError`` ("Password
    was not given but private key is encrypted" on older releases) or
    ``ValueError`` (newer releases), depending on version — we catch both
    and only translate when the message mentions encryption/password.
    Malformed non-protected PEMs keep bubbling up as plain ``ValueError``.
    """
    try:
        private_key = load_ssh_private_key(priv_bytes, password=None)
    except (TypeError, ValueError) as exc:
        if isinstance(exc, TypeError) or _PASSPHRASE_HINT.search(str(exc)):
            raise PasswordProtectedKeyError("private key is passphrase-protected") from exc
        raise

    if not isinstance(private_key, (ed25519.Ed25519PrivateKey, rsa.RSAPrivateKey)):
        raise ValueError(
            f"Unsupported key algorithm: {type(private_key).__name__} (expected Ed25519 or RSA)"
        )

    key_type = _classify_key(private_key)
    private_der = _serialize_private_der(private_key)
    derived_blob, _derived_line = _serialize_public(private_key.public_key(), comment="")

    if pub_bytes is None:
        public_blob = derived_blob
        pub_comment = ""
    else:
        public_blob, pub_comment = _parse_public_line(pub_bytes)
        if public_blob != derived_blob:
            raise KeypairMismatchError("public key does not match private key")

    comment = comment_override if comment_override is not None else pub_comment
    _require_safe_comment(comment)
    algo = _algo_name(key_type)
    public_line = f"{algo} {base64.b64encode(public_blob).decode('ascii')} {comment}".rstrip()
    return GeneratedKeypair(
        key_type=key_type,
        private_der=private_der,
        public_blob=public_blob,
        public_line=public_line,
        comment=comment,
        fingerprint=fingerprint_of(public_blob),
    )

export_ssh_keypair(db, scope, out_dir, key_id=None, out_name=None)

Write a scope's key back out as a standard OpenSSH file pair.

The private bytes come out of the DB as PKCS#8 DER; this function re-armors them as OpenSSH PEM — the same format ssh-keygen writes and that ssh -i consumes. The directory is allowed to contain unrelated files; only the output files are protected with O_EXCL so nothing gets silently clobbered. Default filename stem: id_<keytype>_<fp8> where fp8 is the first eight hex chars of the raw SHA-256 digest of the public blob — stable and format-agnostic for the user-facing fingerprint string.

Source code in src/terok_sandbox/vault/ssh/keypair.py
def export_ssh_keypair(
    db: CredentialDB,
    scope: str,
    out_dir: Path,
    key_id: int | None = None,
    out_name: str | None = None,
) -> ExportResult:
    """Write a scope's key back out as a standard OpenSSH file pair.

    The private bytes come out of the DB as PKCS#8 DER; this function
    re-armors them as OpenSSH PEM — the same format ``ssh-keygen`` writes
    and that ``ssh -i`` consumes.  The directory is allowed to contain
    unrelated files; only the *output* files are protected with ``O_EXCL``
    so nothing gets silently clobbered.  Default filename stem:
    ``id_<keytype>_<fp8>`` where ``fp8`` is the first eight hex chars of
    the raw SHA-256 digest of the public blob — stable and format-agnostic
    for the user-facing fingerprint string.
    """
    record = _pick_key_for_export(db, scope, key_id)
    stem = _sanitize_out_name(out_name) or f"id_{record.key_type}_{_short_id(record.public_blob)}"
    out_dir = out_dir.expanduser().resolve()
    out_dir.mkdir(parents=True, exist_ok=True)
    priv_path = out_dir / stem
    pub_path = out_dir / f"{stem}.pub"

    _write_exclusive(priv_path, openssh_pem_of(record.private_der), PRIVATE_KEY_MODE)
    try:
        _write_exclusive(
            pub_path,
            (public_line_of(record) + "\n").encode("utf-8"),
            PUBLIC_KEY_MODE,
        )
    except BaseException:
        # Don't leave a lone private key on disk if the matching public
        # write failed; the user would have no way to identify which
        # scope it belongs to without the companion ``.pub`` file.
        priv_path.unlink(missing_ok=True)
        raise

    return ExportResult(
        key_id=record.id,
        fingerprint=record.fingerprint,
        private_path=priv_path,
        public_path=pub_path,
    )

fingerprint_of(public_blob)

Return the canonical OpenSSH fingerprint of public_blob.

Format matches what ssh-keygen -lf, ssh-add -l, GitHub's UI, and gh ssh-key list all print: SHA256:<base64-unpadded> over the raw SHA-256 digest of the SSH wire-format public blob.

Source code in src/terok_sandbox/vault/ssh/keypair.py
def fingerprint_of(public_blob: bytes) -> str:
    """Return the canonical OpenSSH fingerprint of *public_blob*.

    Format matches what ``ssh-keygen -lf``, ``ssh-add -l``, GitHub's UI,
    and ``gh ssh-key list`` all print: ``SHA256:<base64-unpadded>`` over
    the raw SHA-256 digest of the SSH wire-format public blob.
    """
    digest = hashlib.sha256(public_blob).digest()
    return f"SHA256:{base64.b64encode(digest).decode('ascii').rstrip('=')}"

openssh_pem_of(private_der)

Re-armor a stored PKCS#8 DER blob as OpenSSH PEM — the on-disk wire format.

This is what ssh-keygen writes and what ssh -i reads. Exposed for the CLI export path and for test fixtures that need to round-trip a key through the OpenSSH-file form.

Source code in src/terok_sandbox/vault/ssh/keypair.py
def openssh_pem_of(private_der: bytes) -> bytes:
    """Re-armor a stored PKCS#8 DER blob as OpenSSH PEM — the on-disk wire format.

    This is what ``ssh-keygen`` writes and what ``ssh -i`` reads.  Exposed for
    the CLI export path and for test fixtures that need to round-trip a key
    through the OpenSSH-file form.
    """
    key = load_der_private_key(private_der, password=None)
    return key.private_bytes(
        encoding=Encoding.PEM,
        format=PrivateFormat.OpenSSH,
        encryption_algorithm=NoEncryption(),
    )

public_line_of(record)

Render record as the one-line OpenSSH public key form.

Format: <algo> <base64-blob> <comment> — matches what ssh-keygen writes to .pub files and what a remote's deploy-key field expects. Callers that rendered this inline now go through this single helper so the algo-name mapping lives in one place.

Source code in src/terok_sandbox/vault/ssh/keypair.py
def public_line_of(record: SSHKeyRecord) -> str:
    """Render *record* as the one-line OpenSSH public key form.

    Format: ``<algo> <base64-blob> <comment>`` — matches what
    ``ssh-keygen`` writes to ``.pub`` files and what a remote's deploy-key
    field expects.  Callers that rendered this inline now go through this
    single helper so the algo-name mapping lives in one place.
    """
    algo = _algo_name(record.key_type)
    b64 = base64.b64encode(record.public_blob).decode("ascii")
    return f"{algo} {b64} {record.comment}".rstrip()