azkv-ssh-fetch
Fetch SSH private keys from Azure Key Vault and connect to VMs / VMSS instances through Azure Bastion — in one command.
azkv-ssh-fetch (alias akf) wraps the boring half of the operator workflow:
- Authenticate via
DefaultAzureCredential(env vars → managed identity →az login). - Pull the named private key from a Key Vault.
- Write it to
~/.ssh/<name>with mode0600(atomic replace, parent dir tightened to0700). - Shell out to
az network bastion sshwith the right flags. - Optionally shred the key on disconnect.
Why this exists
If your operating model says "private keys live in Key Vault, humans get to them through RBAC, and the only path to the VM is through Bastion" — then operators end up running the same 6-line shell snippet over and over. This packages that snippet, types it, tests it, and removes the foot-guns (wrong perms, stale keys lingering in /tmp, mis-typed resource IDs).
Install
pipx install azkv-ssh-fetch
# or
pip install --user azkv-ssh-fetch
Requires Python 3.10+ and the az CLI on PATH for the connect subcommand.
Quick start
# 1. List SSH-shaped secrets in a vault
akf list --vault pro-zks1-nagios-kv
# 2. Pull a key to ~/.ssh/nagios-ssh (mode 600, atomic)
akf fetch --vault pro-zks1-nagios-kv nagios-ssh
# 3. Fetch + Bastion SSH in one shot
akf connect \
--vault pro-zks1-nagios-kv \
--secret nagios-ssh \
--bastion my-bastion \
--bastion-rg my-bastion-rg \
--target-id "/subscriptions/.../virtualMachineScaleSets/zks1-nagios/virtualMachines/0" \
--username azureuser
Configuration
| Variable | Meaning |
|---|---|
AZKV_VAULT |
Default Key Vault name (overridden by --vault). |
AZKV_DEBUG |
Set to 1 for verbose azure-identity logging on stderr. |
Standard AZURE_* env vars are honored by DefaultAzureCredential. |
Subcommands
list
Show enabled secrets in the vault whose names look like SSH keys (match ssh, key, id_rsa, id_ed25519). Exits 1 if none found, 2 on access errors.
fetch
Pull a single secret and write it to disk.
Usage: akf fetch [OPTIONS] SECRET
Pull SECRET from VAULT and write it locally (chmod 600).
Options:
-v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
-o, --output PATH Destination path. Default: ~/.ssh/<secret>.
connect
Fetch the key, open a Bastion SSH session, and (unless --keep-key) remove the key file on disconnect.
Usage: akf connect [OPTIONS]
Options:
-v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
-s, --secret TEXT KV secret name. [required]
-b, --bastion TEXT Bastion name. [required]
--bastion-rg TEXT Bastion's resource group. [required]
-t, --target-id TEXT Full ARM resource ID of target VM or VMSS instance. [required]
-u, --username TEXT SSH username on the target. [default: azureuser]
--keep-key/--shred-key
Leave the key on disk after disconnect. [default: shred-key]
Security notes
- Always 0600. Keys are written through a sibling tempfile with
0600set viafchmodbefore any bytes touch disk, then atomically renamed. - No shell interpolation. The
azinvocation is a fixedargvlist — noshell=True. - Key shredding.
connectremoves the on-disk copy on disconnect by default. Pass--keep-keyto retain it. - Trust chain. Auth uses
DefaultAzureCredential; nothing custom about token handling.
Development
git clone https://github.com/NaeemH/azkv-ssh-fetch
cd azkv-ssh-fetch
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pre-commit install
pytest
ruff check . && ruff format --check .
mypy src
Test layers
| Layer | What it covers | Where |
|---|---|---|
| Unit | SDK calls mocked at the boundary (tests/test_keyvault.py, tests/test_ssh.py, tests/test_cli.py) |
Every CI run, no network |
| VCR replay | Real azure-keyvault-secrets + azure-identity code paths replayed from recorded cassettes (tests/test_keyvault_vcr.py) |
Every CI run, no network — cassettes live in tests/cassettes/ |
| Smoke | Manual akf list / akf fetch against a personal vault |
Pre-tag, by you |
VCR-marked tests auto-skip when their cassette is missing (so a fresh checkout's CI stays green with zero credentials).
Recording cassettes
VCR cassettes are the trust boundary between live Key Vault data and the
public git history. The scrubber in tests/conftest.py redacts every
Authorization header, every access_token / refresh_token / id_token
body field, every secret value field, every GUID (replaced with the all-zero
GUID), and the vault hostname (replaced with test-vault). Do not record
against a Microsoft-internal tenant (PME/TME) or any customer subscription —
use a personal/MSDN/PAYG sub with a vault dedicated to this purpose.
# 1. Create a personal vault and a test secret named "akf-test-key"
# (any string value -- the scrubber redacts it before commit).
az login
export AZURE_TENANT_ID=<your-tenant-guid>
export AZURE_SUBSCRIPTION_ID=<your-personal-sub-guid>
export AZKV_TEST_RECORD_VAULT=<your-personal-vault-name>
# 2. Opt in to recording. This both bypasses the "missing cassette"
# skip and flips the vcr_config record_mode from "none" to "once".
export AZKV_RECORDING=1
# 3. (Optional but recommended for MSA-rooted subscriptions.) Pin token
# acquisition to a specific tenant. Some vaults — notably those in
# Azure Free subscriptions whose root identity is an MSA — emit a
# WWW-Authenticate challenge that names a tenant the principal does
# not belong to. The test fixture's _PinnedTenantCliCredential calls
# `az account get-access-token --tenant <pinned>` directly and ignores
# the bogus challenge tenant.
export AZKV_AUTH_TENANT_ID=<your-tenant-guid>
# 4. Record. The conftest scrubbers run on each request/response as it's
# written to disk.
pytest --record-mode=once tests/test_keyvault_vcr.py
# 5. **Eyeball every cassette before committing.** Confirm:
# - Every Authorization header reads "REDACTED"
# - Every secret-fetch "value" body field reads "REDACTED" (paged list
# responses keep `"value": [...]` because that wrapper isn't a secret)
# - No real GUID appears (only 00000000-0000-0000-0000-000000000000)
# - The vault hostname is "test-vault.vault.azure.net" everywhere
# - No JWT-looking token bodies (search for `Bearer eyJ`)
grep -iE 'bearer [a-z0-9]|access_token|naeemhossain|<your-tenant>|<your-vault>' \
tests/cassettes/**/*.yaml
# 6. If all looks clean, commit. CI will replay them with record_mode=none.
git add tests/cassettes/ && git commit
The scrubbers are defense-in-depth; the human eyeball at step 5 is the actual safety mechanism.
License
MIT © 2026 Naeem Hossain
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file azkv_ssh_fetch-0.1.2.tar.gz.
File metadata
- Download URL: azkv_ssh_fetch-0.1.2.tar.gz
- Upload date:
- Size: 20.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
184049f4e9a2a708998a318df78758a0cc88811f8253d5b7fd89edec568a73df
|
|
| MD5 |
bf385a6777d6dddf325a89ade59bd91f
|
|
| BLAKE2b-256 |
83cda102c16036c1374538c7f1be9c0529be1ed050f4a3348e0cdd412e741a46
|
Provenance
The following attestation bundles were made for azkv_ssh_fetch-0.1.2.tar.gz:
Publisher:
release.yml on NaeemH/azkv-ssh-fetch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
azkv_ssh_fetch-0.1.2.tar.gz -
Subject digest:
184049f4e9a2a708998a318df78758a0cc88811f8253d5b7fd89edec568a73df - Sigstore transparency entry: 1916907482
- Sigstore integration time:
-
Permalink:
NaeemH/azkv-ssh-fetch@cc237ec9ae3ba1795a38dd29d1fa533a10a2b872 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/NaeemH
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cc237ec9ae3ba1795a38dd29d1fa533a10a2b872 -
Trigger Event:
push
-
Statement type:
File details
Details for the file azkv_ssh_fetch-0.1.2-py3-none-any.whl.
File metadata
- Download URL: azkv_ssh_fetch-0.1.2-py3-none-any.whl
- Upload date:
- Size: 13.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c9f0328e14aa3627ab5f7583697b6fbff18618a620761dc01c9ed520287984c4
|
|
| MD5 |
846ec1acb0f66e9ef6043898525a678d
|
|
| BLAKE2b-256 |
3a579cfc4df230c04fb5f2400ad6dca4a5ac442a193bdcdba7977bf5100907d8
|
Provenance
The following attestation bundles were made for azkv_ssh_fetch-0.1.2-py3-none-any.whl:
Publisher:
release.yml on NaeemH/azkv-ssh-fetch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
azkv_ssh_fetch-0.1.2-py3-none-any.whl -
Subject digest:
c9f0328e14aa3627ab5f7583697b6fbff18618a620761dc01c9ed520287984c4 - Sigstore transparency entry: 1916907672
- Sigstore integration time:
-
Permalink:
NaeemH/azkv-ssh-fetch@cc237ec9ae3ba1795a38dd29d1fa533a10a2b872 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/NaeemH
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@cc237ec9ae3ba1795a38dd29d1fa533a10a2b872 -
Trigger Event:
push
-
Statement type: