Skip to content

credentials

Store extension repository credentials so no secret reaches the config in the clear.

One extension has one credential, and this module is the only thing that reads or writes it. It lives in the extension's own config section (MyTool.extension) under a single key:

.. code-block:: ini

[MyTool.extension]
private_repo = true
credential = "<base64 of a DPAPI-sealed blob>"

Examples:

from pyrevit.coreutils import credentials

credentials.set_credential("MyTool.extension", "oauth2", token)
cred = credentials.get_credential("MyTool.extension")
git.git_clone(url, dest, username=cred.username, password=cred.secret)

Why a single key, and why the username inside it: the alternative - a plaintext token/password/username triple per extension - is what a migration has to clear up, and every way of doing that can remove a secret while leaving the username that gave it meaning, which breaks authentication permanently and silently. One blob means clearing a credential is always one key removal, and there is no half-migrated state to reason about.

The crypto lives in C#, not here. This module owns the config contract and the migration; the sealing itself is pyRevitLabs.Configurations.Security.ExtensionCredentialProtector, in the assembly that already backs the user config service. The only thing the two sides share is the stored format: three base64 fields joined with '.', a separator the base64 alphabet cannot contain, UTF-8 encoded and sealed with DPAPI against the current Windows user under a fixed entropy. That is what lets the out-of-Revit CLI store a credential the in-Revit extension manager can read, and the reverse. Changing the format on one side alone orphans every stored credential, so the two must change together.

Scope is the current Windows user. The blob survives pyRevit upgrades and config backups, and is unreadable after an OS reinstall without a profile backup, on another machine, and under another account. That is the trade for a secret that is not sitting in a file every local user can read: a lost profile means re-entering the token. :func:get_credential therefore reports that case as :class:PyRevitCredentialUnavailable rather than returning None, so a caller can tell "re-enter your token" from "this extension needs no token" and fail with a message about the credential instead of letting libgit2 fall back to an anonymous fetch.

Note

Nothing here is reachable from a non-Windows runtime; :func:is_available reports whether sealing works, and every entry point degrades to a logged no-op rather than raising at import time, so importing this module from :mod:pyrevit.versionmgr.updater can never take the updater down.

Attributes

mlogger = get_logger(__name__) module-attribute

CONFIG_KEY = 'credential' module-attribute

LEGACY_CREDENTIAL_KEYS = ('token', 'password', 'username') module-attribute

EXTENSION_SECTION_POSTFIXES = ('.extension', '.lib') module-attribute

DEFAULT_TOKEN_USERNAME = 'oauth2' module-attribute

CREDENTIAL_KINDS = ('token', 'password') module-attribute

Classes

PyRevitCredentialError

Bases: Exception

A credential could not be read or stored.

PyRevitCredentialUnavailable

Bases: PyRevitCredentialError

A stored credential exists but cannot be decrypted.

Important: never treat this as "no credential is configured". A fetch that answers with None here falls back to an anonymous request and surfaces as a libgit2 error about a missing authentication callback, which points at the wrong problem. The user has to re-enter their token; no local action recovers it.

PyRevitCredentialStoreReadOnly

Bases: PyRevitCredentialError

A credential write was aimed at a read-only (admin-locked) config.

Raised instead of silently dropping the value, because user_config.save_changes() is a no-op on a read-only config: reporting success there would leave the user believing a token is stored when it is not.

ExtensionCredential(username, secret, kind='token')

Bases: object

A username and secret for a private repository, recovered from storage.

Attributes:

Name Type Description
username str

account name to send with the secret

secret str

personal access token or password

kind str

"token" or "password", as stored

Build a credential.

Parameters:

Name Type Description Default
username str

account name

required
secret str

token or password

required
kind str

"token" or "password"

'token'
Source code in pyrevitlib/pyrevit/coreutils/credentials.py
def __init__(self, username, secret, kind="token"):
    """Build a credential.

    Args:
        username (str): account name
        secret (str): token or password
        kind (str): ``"token"`` or ``"password"``
    """
    self.username = username
    self.secret = secret
    self.kind = kind

Attributes

username = username instance-attribute
secret = secret instance-attribute
kind = kind instance-attribute

Functions:

is_available()

Whether this runtime can seal and unseal a credential.

Returns:

Type Description
bool

False when the config is read-only, or when the DPAPI assembly could not be loaded. Callers that only need to read a credential should still try :func:get_credential; a False here is the common cause of a token silently never being stored.

Source code in pyrevitlib/pyrevit/coreutils/credentials.py
def is_available():
    """Whether this runtime can seal and unseal a credential.

    Returns:
        (bool): False when the config is read-only, or when the DPAPI assembly
            could not be loaded. Callers that only need to *read* a credential
            should still try :func:`get_credential`; a False here is the common
            cause of a token silently never being stored.
    """
    if _load_crypto() is None:
        return False
    return not _is_readonly()

has_credential(section_name)

Whether a sealed credential is stored for this extension.

True for a stored value that will not decrypt - that is still a configured credential, and the difference matters to the user, who has a token to re-enter. Use :func:get_credential to find out whether it is usable.

Parameters:

Name Type Description Default
section_name str

extension config section name

required

Returns:

Type Description
bool

whether the credential key is present

Source code in pyrevitlib/pyrevit/coreutils/credentials.py
def has_credential(section_name):
    """Whether a sealed credential is stored for this extension.

    True for a stored value that will not decrypt - that is still a configured
    credential, and the difference matters to the user, who has a token to
    re-enter. Use :func:`get_credential` to find out whether it is usable.

    Args:
        section_name (str): extension config section name

    Returns:
        (bool): whether the credential key is present
    """
    return _read_raw(section_name) is not None

get_credential(section_name)

Recover the stored credential for an extension.

Parameters:

Name Type Description Default
section_name str

extension config section name, e.g. extpkg.config_section_name or repo_info.name. For an installed extension both are the extension folder name including its .extension / .lib postfix.

required

Returns:

Type Description
ExtensionCredential

the credential, or None when none is stored

Raises:

Type Description
PyRevitCredentialUnavailable

one is stored but cannot be decrypted. This is deliberately not reported as None - see the module docstring.

Source code in pyrevitlib/pyrevit/coreutils/credentials.py
def get_credential(section_name):
    """Recover the stored credential for an extension.

    Args:
        section_name (str): extension config section name, e.g.
            ``extpkg.config_section_name`` or ``repo_info.name``. For an
            installed extension both are the extension folder name including its
            ``.extension`` / ``.lib`` postfix.

    Returns:
        (ExtensionCredential): the credential, or None when none is stored

    Raises:
        PyRevitCredentialUnavailable: one is stored but cannot be decrypted. This
            is deliberately not reported as None - see the module docstring.
    """
    stored = _read_raw(section_name)
    if stored is None:
        return None
    return _unseal(stored)

set_credential(section_name, username, secret, kind='token')

Seal a credential and store it in the extension's config section.

The new value is read back from the config file and matched against what was written before anything else is touched, so a failure part-way through leaves the working credential in place instead of deleting it. That is the one ordering that cannot lose a token: the alternative - clearing the old keys first - destroys the only usable copy whenever sealing then fails.

Note

A flush that does not land is reported as a failure even when the section already held a credential, because the file would then still hold the old one. The in-memory value is rolled back to match the file, so what get_credential reports is what the next session will see.

Parameters:

Name Type Description Default
section_name str

extension config section name

required
username str

account name. Pass DEFAULT_TOKEN_USERNAME for a GitHub token; the value is stored inside the sealed blob.

required
secret str

token or password

required
kind str

"token" or "password"

'token'

Raises:

Type Description
PyRevitCredentialStoreReadOnly

the config is admin-locked, so the value would have been dropped silently

PyRevitCredentialError

the credential is empty, or DPAPI is unavailable

PyRevitCredentialUnavailable

the value could not be verified after being written

Source code in pyrevitlib/pyrevit/coreutils/credentials.py
def set_credential(section_name, username, secret, kind="token"):
    """Seal a credential and store it in the extension's config section.

    The new value is read back from the config file and matched against what was
    written before anything else is touched, so a failure part-way through leaves
    the working credential in place instead of deleting it. That is the one
    ordering that cannot lose a token: the alternative - clearing the old keys
    first - destroys the only usable copy whenever sealing then fails.

    Note:
        A flush that does not land is reported as a failure even when the section
        already held a credential, because the file would then still hold the old
        one. The in-memory value is rolled back to match the file, so what
        ``get_credential`` reports is what the next session will see.

    Args:
        section_name (str): extension config section name
        username (str): account name. Pass ``DEFAULT_TOKEN_USERNAME`` for a
            GitHub token; the value is stored inside the sealed blob.
        secret (str): token or password
        kind (str): ``"token"`` or ``"password"``

    Raises:
        PyRevitCredentialStoreReadOnly: the config is admin-locked, so the value
            would have been dropped silently
        PyRevitCredentialError: the credential is empty, or DPAPI is unavailable
        PyRevitCredentialUnavailable: the value could not be verified after being
            written
    """
    if _is_readonly():
        raise PyRevitCredentialStoreReadOnly(
            "The pyRevit config is admin-locked (read-only), so a credential for "
            "[{}] cannot be stored. Unlock the config or set the token on every "
            "command line.".format(section_name)
        )

    sealed = _seal(username, secret, kind)

    section = _get_section(section_name, create=True)
    if section is None:
        raise PyRevitCredentialError(
            "Can not open the config section [{}] to store a credential.".format(
                section_name
            )
        )

    try:
        section.set_option(CONFIG_KEY, sealed)
        _save()
    except Exception as write_err:
        raise PyRevitCredentialError(
            "Can not store the credential for [{}]: {}".format(section_name, write_err)
        )

    # Verify against the file, not the in-memory store: save_changes swallows a
    # failed flush, so only a re-read of the file can tell a stored credential
    # from an accepted-and-dropped one. The file has to hold *this* value, not
    # merely some value: a rotation whose flush was dropped still leaves the
    # previous credential readable, and accepting that would report success while
    # a restart goes on using the old token. Nothing is removed until this passes.
    #
    # A verification that cannot be performed leaves the outcome unknown, so the
    # in-memory value is dropped rather than kept: get_credential reads memory,
    # and a credential that only exists in memory is one a restart will not have.
    try:
        stored = _read_from_disk(section_name)
    except Exception:
        _restore_stored_value(section, None)
        raise

    if not _is_stored_credential(stored, username, secret, kind):
        _restore_stored_value(section, stored)
        raise PyRevitCredentialUnavailable(
            "The credential for [{}] was not stored: the config file does not "
            "hold the value that was just written. The file may be read-only or "
            "the write may have been refused, so the credential that was already "
            "in place has been kept.".format(section_name)
        )

    if _remove_legacy_keys(section):
        _save()
        remaining = [
            key
            for key in LEGACY_CREDENTIAL_KEYS
            if _read_from_disk(section_name, key) is not None
        ]
        if remaining:
            raise PyRevitCredentialUnavailable(
                "The credential for [{}] was encrypted, but the plaintext {} "
                "could not be removed from the config file. Delete those keys by "
                "hand: until then the secret is still readable on disk.".format(
                    section_name, ", ".join(remaining)
                )
            )

    mlogger.info("credentials: stored an encrypted credential for [%s]", section_name)

delete_credential(section_name)

Remove the stored credential for an extension.

Also clears private_repo, which would otherwise be left claiming a credential that no longer exists.

The removal is read back off disk before it is reported, for the same reason :func:set_credential verifies its write: save_changes swallows a failed flush, so the in-memory store would report a cleared credential that a restart brings straight back.

Parameters:

Name Type Description Default
section_name str

extension config section name

required

Returns:

Type Description
bool

whether a credential was there to remove, and is now gone from

the config file

Raises:

Type Description
PyRevitCredentialStoreReadOnly

the config is admin-locked

PyRevitCredentialUnavailable

the credential was removed in memory but is still in the config file, so it will return on the next start

Source code in pyrevitlib/pyrevit/coreutils/credentials.py
def delete_credential(section_name):
    """Remove the stored credential for an extension.

    Also clears ``private_repo``, which would otherwise be left claiming a
    credential that no longer exists.

    The removal is read back off disk before it is reported, for the same reason
    :func:`set_credential` verifies its write: ``save_changes`` swallows a failed
    flush, so the in-memory store would report a cleared credential that a
    restart brings straight back.

    Args:
        section_name (str): extension config section name

    Returns:
        (bool): whether a credential was there to remove, and is now gone from
        the config file

    Raises:
        PyRevitCredentialStoreReadOnly: the config is admin-locked
        PyRevitCredentialUnavailable: the credential was removed in memory but
            is still in the config file, so it will return on the next start
    """
    if _is_readonly():
        raise PyRevitCredentialStoreReadOnly(
            "The pyRevit config is admin-locked (read-only), so the credential for "
            "[{}] cannot be cleared.".format(section_name)
        )

    section = _get_section(section_name)
    if section is None:
        return False

    removed = _remove_key(section, CONFIG_KEY)
    removed = _remove_legacy_keys(section) or removed
    if not removed:
        return False

    try:
        section.set_option("private_repo", False)
        _save()
    except Exception as flag_err:
        mlogger.warning(
            "credentials: removed the credential for [%s] but could not clear "
            "its private_repo flag: %s",
            section_name,
            flag_err,
        )

    still_stored = _read_from_disk(section_name) is not None
    still_plaintext = any(
        _read_from_disk(section_name, key) is not None for key in LEGACY_CREDENTIAL_KEYS
    )
    if still_stored or still_plaintext:
        raise PyRevitCredentialUnavailable(
            "The credential for [{}] was cleared in memory but is still in the "
            "config file, so it will come back on the next start. The file may "
            "be read-only or the write may have been refused.".format(section_name)
        )

    mlogger.info("credentials: cleared the stored credential for [%s]", section_name)
    return True

migrate_legacy_credentials()

Seal any plaintext credential still sitting in an extension config section.

Older pyRevit wrote token / password / username straight into the config file, and the CLI still could until --persist-credentials learned to seal. This runs once per session from :func:pyrevit.versionmgr.upgrade.upgrade_existing_pyrevit.

Every *.extension / *.lib section is considered, not just the extensions currently installed and authorized: a credential for an extension that is temporarily uninstalled, or sits in a path that fails authorization, would otherwise stay in the clear indefinitely.

A section is only cleared after the sealed value is stored and verified by decrypting it back. A section that fails to seal keeps its plaintext and is reported, because losing a working token is worse than leaving it where it was.

Returns:

Type Description
int

how many sections were migrated

Source code in pyrevitlib/pyrevit/coreutils/credentials.py
def migrate_legacy_credentials():
    """Seal any plaintext credential still sitting in an extension config section.

    Older pyRevit wrote ``token`` / ``password`` / ``username`` straight into the
    config file, and the CLI still could until ``--persist-credentials`` learned
    to seal. This runs once per session from
    :func:`pyrevit.versionmgr.upgrade.upgrade_existing_pyrevit`.

    Every ``*.extension`` / ``*.lib`` section is considered, not just the
    extensions currently installed and authorized: a credential for an extension
    that is temporarily uninstalled, or sits in a path that fails authorization,
    would otherwise stay in the clear indefinitely.

    A section is only cleared after the sealed value is stored **and** verified
    by decrypting it back. A section that fails to seal keeps its plaintext and
    is reported, because losing a working token is worse than leaving it where it
    was.

    Returns:
        (int): how many sections were migrated
    """
    from pyrevit.userconfig import user_config

    if user_config is None:
        return 0

    # Checked once, up front: on an admin-locked config nothing can be sealed, and
    # walking every section first would emit the same refusal per extension.
    if _is_readonly():
        mlogger.warning(
            "credentials: the pyRevit config is admin-locked (read-only), so "
            "plaintext extension credentials can not be encrypted. They are left "
            "as they are."
        )
        return 0

    try:
        section_names = list(user_config)
    except Exception as iter_err:
        mlogger.warning("credentials: can not enumerate config sections: %s", iter_err)
        return 0

    migrated = 0
    for section_name in section_names:
        if not section_name.endswith(EXTENSION_SECTION_POSTFIXES):
            continue
        try:
            if _migrate_legacy_section(section_name):
                migrated += 1
        except Exception as section_err:
            mlogger.warning(
                "credentials: could not migrate [%s]: %s", section_name, section_err
            )

    if migrated:
        try:
            _save()
        except Exception as save_err:
            mlogger.warning(
                "credentials: migrated %s section(s) but could not save the config: %s",
                migrated,
                save_err,
            )
        mlogger.info(
            "credentials: encrypted %s plaintext extension credential(s)", migrated
        )

    return migrated