azkv-ssh-fetch 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: ${{ matrix.python-version }} on ${{ matrix.os }}
15
+ runs-on: ${{ matrix.os }}
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ os: [ubuntu-latest]
20
+ python-version: ["3.10", "3.11", "3.12"]
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Set up Python ${{ matrix.python-version }}
25
+ uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ cache: pip
29
+
30
+ - name: Install
31
+ run: |
32
+ python -m pip install --upgrade pip
33
+ pip install -e ".[dev]"
34
+
35
+ - name: Lint (ruff)
36
+ run: |
37
+ ruff check .
38
+ ruff format --check .
39
+
40
+ - name: Type-check (mypy)
41
+ run: mypy src
42
+
43
+ - name: Test (pytest)
44
+ run: pytest -q
@@ -0,0 +1,50 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ name: Build distribution
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ - name: Install build tooling
23
+ run: python -m pip install --upgrade pip build
24
+
25
+ - name: Build sdist + wheel
26
+ run: python -m build
27
+
28
+ - uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ publish:
34
+ name: Publish to PyPI
35
+ needs: build
36
+ runs-on: ubuntu-latest
37
+ # PyPI trusted publishing (OIDC). Requires the project to be pre-registered
38
+ # on PyPI with this GitHub repo + workflow allow-listed.
39
+ environment:
40
+ name: pypi
41
+ url: https://pypi.org/p/azkv-ssh-fetch
42
+ permissions:
43
+ id-token: write
44
+ steps:
45
+ - uses: actions/download-artifact@v4
46
+ with:
47
+ name: dist
48
+ path: dist/
49
+
50
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,52 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Distribution / packaging
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+
25
+ # Virtual environments
26
+ .venv/
27
+ venv/
28
+ env/
29
+ .env
30
+
31
+ # Tooling caches
32
+ .tox/
33
+ .nox/
34
+ .coverage
35
+ .coverage.*
36
+ .cache
37
+ .pytest_cache/
38
+ .ruff_cache/
39
+ .mypy_cache/
40
+ htmlcov/
41
+ coverage.xml
42
+ *.cover
43
+ .hypothesis/
44
+
45
+ # IDE
46
+ .idea/
47
+ .vscode/
48
+ *.swp
49
+
50
+ # OS
51
+ .DS_Store
52
+ Thumbs.db
@@ -0,0 +1,28 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.5.7
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix, --exit-non-zero-on-fix]
7
+ - id: ruff-format
8
+
9
+ - repo: https://github.com/pre-commit/mirrors-mypy
10
+ rev: v1.10.1
11
+ hooks:
12
+ - id: mypy
13
+ args: [--strict]
14
+ additional_dependencies:
15
+ - typer>=0.12
16
+ - rich>=13.7
17
+ - azure-identity>=1.17
18
+ - azure-keyvault-secrets>=4.8
19
+
20
+ - repo: https://github.com/pre-commit/pre-commit-hooks
21
+ rev: v4.6.0
22
+ hooks:
23
+ - id: check-yaml
24
+ - id: check-toml
25
+ - id: end-of-file-fixer
26
+ - id: trailing-whitespace
27
+ - id: check-merge-conflict
28
+ - id: check-added-large-files
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Naeem Hossain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: azkv-ssh-fetch
3
+ Version: 0.1.0
4
+ Summary: Fetch SSH private keys from Azure Key Vault and connect to VMs/VMSS via Bastion.
5
+ Project-URL: Homepage, https://github.com/NaeemH/azkv-ssh-fetch
6
+ Project-URL: Repository, https://github.com/NaeemH/azkv-ssh-fetch
7
+ Project-URL: Issues, https://github.com/NaeemH/azkv-ssh-fetch/issues
8
+ Author-email: Naeem Hossain <naeemhossain.mail@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Naeem Hossain
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in
21
+ all copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
29
+ THE SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: azure,bastion,devops,key-vault,ssh,vmss
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Environment :: Console
34
+ Classifier: Intended Audience :: System Administrators
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: MacOS
37
+ Classifier: Operating System :: POSIX :: Linux
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Topic :: System :: Systems Administration
43
+ Requires-Python: >=3.10
44
+ Requires-Dist: azure-identity>=1.17
45
+ Requires-Dist: azure-keyvault-secrets>=4.8
46
+ Requires-Dist: rich>=13.7
47
+ Requires-Dist: typer>=0.12
48
+ Provides-Extra: dev
49
+ Requires-Dist: mypy>=1.10; extra == 'dev'
50
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
51
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
52
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
53
+ Requires-Dist: pytest>=8.0; extra == 'dev'
54
+ Requires-Dist: ruff>=0.5; extra == 'dev'
55
+ Description-Content-Type: text/markdown
56
+
57
+ # azkv-ssh-fetch
58
+
59
+ [![CI](https://github.com/NaeemH/azkv-ssh-fetch/actions/workflows/ci.yml/badge.svg)](https://github.com/NaeemH/azkv-ssh-fetch/actions/workflows/ci.yml)
60
+ [![PyPI](https://img.shields.io/pypi/v/azkv-ssh-fetch.svg)](https://pypi.org/project/azkv-ssh-fetch/)
61
+ [![Python](https://img.shields.io/pypi/pyversions/azkv-ssh-fetch.svg)](https://pypi.org/project/azkv-ssh-fetch/)
62
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
63
+
64
+ > Fetch SSH private keys from **Azure Key Vault** and connect to VMs / VMSS instances through **Azure Bastion** — in one command.
65
+
66
+ `azkv-ssh-fetch` (alias `akf`) wraps the boring half of the operator workflow:
67
+
68
+ 1. Authenticate via `DefaultAzureCredential` (env vars → managed identity → `az login`).
69
+ 2. Pull the named private key from a Key Vault.
70
+ 3. Write it to `~/.ssh/<name>` with mode `0600` (atomic replace, parent dir tightened to `0700`).
71
+ 4. Shell out to `az network bastion ssh` with the right flags.
72
+ 5. Optionally shred the key on disconnect.
73
+
74
+ ## Why this exists
75
+
76
+ 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).
77
+
78
+ ## Install
79
+
80
+ ```bash
81
+ pipx install azkv-ssh-fetch
82
+ # or
83
+ pip install --user azkv-ssh-fetch
84
+ ```
85
+
86
+ Requires Python 3.10+ and the `az` CLI on `PATH` for the `connect` subcommand.
87
+
88
+ ## Quick start
89
+
90
+ ```bash
91
+ # 1. List SSH-shaped secrets in a vault
92
+ akf list --vault pro-zks1-nagios-kv
93
+
94
+ # 2. Pull a key to ~/.ssh/nagios-ssh (mode 600, atomic)
95
+ akf fetch --vault pro-zks1-nagios-kv nagios-ssh
96
+
97
+ # 3. Fetch + Bastion SSH in one shot
98
+ akf connect \
99
+ --vault pro-zks1-nagios-kv \
100
+ --secret nagios-ssh \
101
+ --bastion my-bastion \
102
+ --bastion-rg my-bastion-rg \
103
+ --target-id "/subscriptions/.../virtualMachineScaleSets/zks1-nagios/virtualMachines/0" \
104
+ --username azureuser
105
+ ```
106
+
107
+ ## Configuration
108
+
109
+ | Variable | Meaning |
110
+ | ------------- | ------------------------------------------------------------- |
111
+ | `AZKV_VAULT` | Default Key Vault name (overridden by `--vault`). |
112
+ | `AZKV_DEBUG` | Set to `1` for verbose `azure-identity` logging on stderr. |
113
+ | Standard `AZURE_*` env vars are honored by `DefaultAzureCredential`. |
114
+
115
+ ## Subcommands
116
+
117
+ ### `list`
118
+
119
+ 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.
120
+
121
+ ### `fetch`
122
+
123
+ Pull a single secret and write it to disk.
124
+
125
+ ```
126
+ Usage: akf fetch [OPTIONS] SECRET
127
+
128
+ Pull SECRET from VAULT and write it locally (chmod 600).
129
+
130
+ Options:
131
+ -v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
132
+ -o, --output PATH Destination path. Default: ~/.ssh/<secret>.
133
+ ```
134
+
135
+ ### `connect`
136
+
137
+ Fetch the key, open a Bastion SSH session, and (unless `--keep-key`) remove the key file on disconnect.
138
+
139
+ ```
140
+ Usage: akf connect [OPTIONS]
141
+
142
+ Options:
143
+ -v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
144
+ -s, --secret TEXT KV secret name. [required]
145
+ -b, --bastion TEXT Bastion name. [required]
146
+ --bastion-rg TEXT Bastion's resource group. [required]
147
+ -t, --target-id TEXT Full ARM resource ID of target VM or VMSS instance. [required]
148
+ -u, --username TEXT SSH username on the target. [default: azureuser]
149
+ --keep-key/--shred-key
150
+ Leave the key on disk after disconnect. [default: shred-key]
151
+ ```
152
+
153
+ ## Security notes
154
+
155
+ - **Always 0600.** Keys are written through a sibling tempfile with `0600` set via `fchmod` *before* any bytes touch disk, then atomically renamed.
156
+ - **No shell interpolation.** The `az` invocation is a fixed `argv` list — no `shell=True`.
157
+ - **Key shredding.** `connect` removes the on-disk copy on disconnect by default. Pass `--keep-key` to retain it.
158
+ - **Trust chain.** Auth uses `DefaultAzureCredential`; nothing custom about token handling.
159
+
160
+ ## Development
161
+
162
+ ```bash
163
+ git clone https://github.com/NaeemH/azkv-ssh-fetch
164
+ cd azkv-ssh-fetch
165
+ python -m venv .venv && source .venv/bin/activate
166
+ pip install -e ".[dev]"
167
+ pre-commit install
168
+ pytest
169
+ ruff check . && ruff format --check .
170
+ mypy src
171
+ ```
172
+
173
+ ## License
174
+
175
+ [MIT](LICENSE) © 2026 Naeem Hossain
@@ -0,0 +1,119 @@
1
+ # azkv-ssh-fetch
2
+
3
+ [![CI](https://github.com/NaeemH/azkv-ssh-fetch/actions/workflows/ci.yml/badge.svg)](https://github.com/NaeemH/azkv-ssh-fetch/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/azkv-ssh-fetch.svg)](https://pypi.org/project/azkv-ssh-fetch/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/azkv-ssh-fetch.svg)](https://pypi.org/project/azkv-ssh-fetch/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+
8
+ > Fetch SSH private keys from **Azure Key Vault** and connect to VMs / VMSS instances through **Azure Bastion** — in one command.
9
+
10
+ `azkv-ssh-fetch` (alias `akf`) wraps the boring half of the operator workflow:
11
+
12
+ 1. Authenticate via `DefaultAzureCredential` (env vars → managed identity → `az login`).
13
+ 2. Pull the named private key from a Key Vault.
14
+ 3. Write it to `~/.ssh/<name>` with mode `0600` (atomic replace, parent dir tightened to `0700`).
15
+ 4. Shell out to `az network bastion ssh` with the right flags.
16
+ 5. Optionally shred the key on disconnect.
17
+
18
+ ## Why this exists
19
+
20
+ 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).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pipx install azkv-ssh-fetch
26
+ # or
27
+ pip install --user azkv-ssh-fetch
28
+ ```
29
+
30
+ Requires Python 3.10+ and the `az` CLI on `PATH` for the `connect` subcommand.
31
+
32
+ ## Quick start
33
+
34
+ ```bash
35
+ # 1. List SSH-shaped secrets in a vault
36
+ akf list --vault pro-zks1-nagios-kv
37
+
38
+ # 2. Pull a key to ~/.ssh/nagios-ssh (mode 600, atomic)
39
+ akf fetch --vault pro-zks1-nagios-kv nagios-ssh
40
+
41
+ # 3. Fetch + Bastion SSH in one shot
42
+ akf connect \
43
+ --vault pro-zks1-nagios-kv \
44
+ --secret nagios-ssh \
45
+ --bastion my-bastion \
46
+ --bastion-rg my-bastion-rg \
47
+ --target-id "/subscriptions/.../virtualMachineScaleSets/zks1-nagios/virtualMachines/0" \
48
+ --username azureuser
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ | Variable | Meaning |
54
+ | ------------- | ------------------------------------------------------------- |
55
+ | `AZKV_VAULT` | Default Key Vault name (overridden by `--vault`). |
56
+ | `AZKV_DEBUG` | Set to `1` for verbose `azure-identity` logging on stderr. |
57
+ | Standard `AZURE_*` env vars are honored by `DefaultAzureCredential`. |
58
+
59
+ ## Subcommands
60
+
61
+ ### `list`
62
+
63
+ 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.
64
+
65
+ ### `fetch`
66
+
67
+ Pull a single secret and write it to disk.
68
+
69
+ ```
70
+ Usage: akf fetch [OPTIONS] SECRET
71
+
72
+ Pull SECRET from VAULT and write it locally (chmod 600).
73
+
74
+ Options:
75
+ -v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
76
+ -o, --output PATH Destination path. Default: ~/.ssh/<secret>.
77
+ ```
78
+
79
+ ### `connect`
80
+
81
+ Fetch the key, open a Bastion SSH session, and (unless `--keep-key`) remove the key file on disconnect.
82
+
83
+ ```
84
+ Usage: akf connect [OPTIONS]
85
+
86
+ Options:
87
+ -v, --vault TEXT Key Vault name. [env: AZKV_VAULT; required]
88
+ -s, --secret TEXT KV secret name. [required]
89
+ -b, --bastion TEXT Bastion name. [required]
90
+ --bastion-rg TEXT Bastion's resource group. [required]
91
+ -t, --target-id TEXT Full ARM resource ID of target VM or VMSS instance. [required]
92
+ -u, --username TEXT SSH username on the target. [default: azureuser]
93
+ --keep-key/--shred-key
94
+ Leave the key on disk after disconnect. [default: shred-key]
95
+ ```
96
+
97
+ ## Security notes
98
+
99
+ - **Always 0600.** Keys are written through a sibling tempfile with `0600` set via `fchmod` *before* any bytes touch disk, then atomically renamed.
100
+ - **No shell interpolation.** The `az` invocation is a fixed `argv` list — no `shell=True`.
101
+ - **Key shredding.** `connect` removes the on-disk copy on disconnect by default. Pass `--keep-key` to retain it.
102
+ - **Trust chain.** Auth uses `DefaultAzureCredential`; nothing custom about token handling.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ git clone https://github.com/NaeemH/azkv-ssh-fetch
108
+ cd azkv-ssh-fetch
109
+ python -m venv .venv && source .venv/bin/activate
110
+ pip install -e ".[dev]"
111
+ pre-commit install
112
+ pytest
113
+ ruff check . && ruff format --check .
114
+ mypy src
115
+ ```
116
+
117
+ ## License
118
+
119
+ [MIT](LICENSE) © 2026 Naeem Hossain
@@ -0,0 +1,90 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "azkv-ssh-fetch"
7
+ version = "0.1.0"
8
+ description = "Fetch SSH private keys from Azure Key Vault and connect to VMs/VMSS via Bastion."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Naeem Hossain", email = "naeemhossain.mail@gmail.com" }]
13
+ keywords = ["azure", "ssh", "key-vault", "bastion", "vmss", "devops"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: System Administrators",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Operating System :: MacOS",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: System :: Systems Administration",
26
+ ]
27
+ dependencies = [
28
+ "typer>=0.12",
29
+ "rich>=13.7",
30
+ "azure-identity>=1.17",
31
+ "azure-keyvault-secrets>=4.8",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "pytest-cov>=5.0",
38
+ "pytest-mock>=3.12",
39
+ "ruff>=0.5",
40
+ "mypy>=1.10",
41
+ "pre-commit>=3.7",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/NaeemH/azkv-ssh-fetch"
46
+ Repository = "https://github.com/NaeemH/azkv-ssh-fetch"
47
+ Issues = "https://github.com/NaeemH/azkv-ssh-fetch/issues"
48
+
49
+ [project.scripts]
50
+ azkv-ssh-fetch = "azkv_ssh_fetch.cli:app"
51
+ akf = "azkv_ssh_fetch.cli:app"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/azkv_ssh_fetch"]
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py310"
59
+ src = ["src", "tests"]
60
+
61
+ [tool.ruff.lint]
62
+ select = [
63
+ "E", # pycodestyle errors
64
+ "W", # pycodestyle warnings
65
+ "F", # pyflakes
66
+ "I", # isort
67
+ "B", # flake8-bugbear
68
+ "C4", # flake8-comprehensions
69
+ "UP", # pyupgrade
70
+ "N", # pep8-naming
71
+ "ARG", # flake8-unused-arguments
72
+ "SIM", # flake8-simplify
73
+ ]
74
+ ignore = ["E501"] # handled by formatter
75
+
76
+ [tool.ruff.format]
77
+ quote-style = "double"
78
+
79
+ [tool.mypy]
80
+ python_version = "3.10"
81
+ strict = true
82
+ warn_return_any = true
83
+ warn_unused_configs = true
84
+ disallow_untyped_defs = true
85
+ ignore_missing_imports = true
86
+
87
+ [tool.pytest.ini_options]
88
+ minversion = "8.0"
89
+ addopts = "-ra --strict-markers --cov=azkv_ssh_fetch --cov-report=term-missing"
90
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """azkv-ssh-fetch — fetch SSH private keys from Azure Key Vault and connect via Bastion."""
2
+
3
+ from azkv_ssh_fetch.__about__ import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Allow `python -m azkv_ssh_fetch ...`."""
2
+
3
+ from azkv_ssh_fetch.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,70 @@
1
+ """Azure Bastion native-client invocation.
2
+
3
+ Shells out to the `az network bastion ssh` command rather than reimplementing the
4
+ RDP/SSH tunnel protocol. Requires `az` CLI >= 2.32 with the `ssh` extension.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from azkv_ssh_fetch.errors import BastionInvocationError
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class BastionTarget:
19
+ """Connection coordinates for a Bastion-fronted VM/VMSS instance."""
20
+
21
+ bastion_name: str
22
+ bastion_resource_group: str
23
+ target_resource_id: str
24
+ """Full ARM resource ID of the target VM or VMSS instance."""
25
+ username: str
26
+
27
+
28
+ def _require_az() -> str:
29
+ """Return path to `az` or raise."""
30
+ az = shutil.which("az")
31
+ if az is None:
32
+ raise BastionInvocationError(
33
+ "the `az` CLI was not found on PATH; install from https://aka.ms/installazcli"
34
+ )
35
+ return az
36
+
37
+
38
+ def ssh_via_bastion(target: BastionTarget, private_key: Path) -> int:
39
+ """Open an interactive SSH session through Azure Bastion.
40
+
41
+ Blocks until the user exits. Returns the SSH exit code.
42
+
43
+ Raises:
44
+ BastionInvocationError: `az` is missing or the command exited with a
45
+ non-SSH error (e.g., RBAC denial, unknown resource).
46
+ """
47
+ az = _require_az()
48
+ cmd = [
49
+ az,
50
+ "network",
51
+ "bastion",
52
+ "ssh",
53
+ "--name",
54
+ target.bastion_name,
55
+ "--resource-group",
56
+ target.bastion_resource_group,
57
+ "--target-resource-id",
58
+ target.target_resource_id,
59
+ "--auth-type",
60
+ "ssh-key",
61
+ "--username",
62
+ target.username,
63
+ "--ssh-key",
64
+ str(private_key),
65
+ ]
66
+ try:
67
+ completed = subprocess.run(cmd, check=False) # noqa: S603 - args are controlled
68
+ except OSError as exc:
69
+ raise BastionInvocationError(f"failed to launch az: {exc}") from exc
70
+ return completed.returncode
@@ -0,0 +1,191 @@
1
+ """Typer CLI: `azkv-ssh-fetch` (alias `akf`).
2
+
3
+ Subcommands:
4
+ list List SSH-shaped secrets in a Key Vault.
5
+ fetch Pull a private key from KV to a local file (chmod 600).
6
+ connect fetch + open Bastion SSH session in one command.
7
+
8
+ All commands accept --vault from --vault flag or AZKV_VAULT env var.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ import typer
19
+ from rich.console import Console
20
+ from rich.table import Table
21
+
22
+ from azkv_ssh_fetch import __version__
23
+ from azkv_ssh_fetch.bastion import BastionTarget, ssh_via_bastion
24
+ from azkv_ssh_fetch.errors import AzkvSshFetchError
25
+ from azkv_ssh_fetch.keyvault import fetch_secret, list_secrets
26
+ from azkv_ssh_fetch.ssh import default_ssh_dir, write_private_key
27
+
28
+ app = typer.Typer(
29
+ name="azkv-ssh-fetch",
30
+ help="Fetch SSH private keys from Azure Key Vault and connect via Bastion.",
31
+ no_args_is_help=True,
32
+ add_completion=True,
33
+ rich_markup_mode="rich",
34
+ )
35
+ stdout = Console()
36
+ stderr = Console(stderr=True)
37
+
38
+ VaultOpt = Annotated[
39
+ str,
40
+ typer.Option(
41
+ "--vault",
42
+ "-v",
43
+ envvar="AZKV_VAULT",
44
+ help="Key Vault name (not full URL). Required, or set [bold]AZKV_VAULT[/bold].",
45
+ ),
46
+ ]
47
+
48
+
49
+ def _version_callback(value: bool) -> None:
50
+ if value:
51
+ stdout.print(f"azkv-ssh-fetch [bold cyan]{__version__}[/bold cyan]")
52
+ raise typer.Exit()
53
+
54
+
55
+ @app.callback()
56
+ def _root(
57
+ _version: Annotated[ # noqa: ARG001 - typer needs the param for the flag
58
+ bool,
59
+ typer.Option(
60
+ "--version",
61
+ callback=_version_callback,
62
+ is_eager=True,
63
+ help="Show version and exit.",
64
+ ),
65
+ ] = False,
66
+ ) -> None:
67
+ """Common options."""
68
+
69
+
70
+ @app.command("list")
71
+ def cmd_list(vault: VaultOpt) -> None:
72
+ """List secrets in [bold]VAULT[/bold] that look like SSH keys."""
73
+ try:
74
+ secrets = list(list_secrets(vault))
75
+ except AzkvSshFetchError as exc:
76
+ stderr.print(f"[red]error:[/red] {exc}")
77
+ raise typer.Exit(code=2) from exc
78
+
79
+ table = Table(title=f"Secrets in {vault}", show_lines=False)
80
+ table.add_column("Name", style="cyan")
81
+ table.add_column("Enabled", justify="center")
82
+ table.add_column("Content-Type", style="dim")
83
+
84
+ shown = 0
85
+ for s in secrets:
86
+ if not s.enabled:
87
+ continue
88
+ # Best-effort filter: SSH keys are usually named *ssh*, *key*, *id_rsa*
89
+ lower = s.name.lower()
90
+ looks_like_key = any(tok in lower for tok in ("ssh", "key", "id_rsa", "id_ed25519"))
91
+ if not looks_like_key:
92
+ continue
93
+ table.add_row(s.name, "yes", s.content_type or "")
94
+ shown += 1
95
+
96
+ if shown == 0:
97
+ stderr.print(
98
+ f"[yellow]no SSH-shaped secrets found in {vault}.[/yellow]"
99
+ " Try [bold]az keyvault secret list[/bold] for the full list."
100
+ )
101
+ raise typer.Exit(code=1)
102
+ stdout.print(table)
103
+
104
+
105
+ @app.command("fetch")
106
+ def cmd_fetch(
107
+ vault: VaultOpt,
108
+ secret: Annotated[str, typer.Argument(help="Secret name in the vault.")],
109
+ output: Annotated[
110
+ Path | None,
111
+ typer.Option(
112
+ "--output",
113
+ "-o",
114
+ help="Destination path. Default: ~/.ssh/<secret>.",
115
+ ),
116
+ ] = None,
117
+ ) -> None:
118
+ """Pull [bold]SECRET[/bold] from [bold]VAULT[/bold] and write it locally (chmod 600)."""
119
+ dest = output if output is not None else default_ssh_dir() / secret
120
+ try:
121
+ material = fetch_secret(vault, secret)
122
+ written = write_private_key(material, dest)
123
+ except AzkvSshFetchError as exc:
124
+ stderr.print(f"[red]error:[/red] {exc}")
125
+ raise typer.Exit(code=2) from exc
126
+ stdout.print(f"[green]wrote[/green] {written} (mode 600)")
127
+
128
+
129
+ @app.command("connect")
130
+ def cmd_connect(
131
+ vault: VaultOpt,
132
+ secret: Annotated[str, typer.Option("--secret", "-s", help="KV secret name.")],
133
+ bastion_name: Annotated[str, typer.Option("--bastion", "-b", help="Bastion name.")],
134
+ bastion_rg: Annotated[str, typer.Option("--bastion-rg", help="Bastion's resource group.")],
135
+ target_id: Annotated[
136
+ str,
137
+ typer.Option(
138
+ "--target-id",
139
+ "-t",
140
+ help="Full ARM resource ID of target VM or VMSS instance.",
141
+ ),
142
+ ],
143
+ username: Annotated[
144
+ str, typer.Option("--username", "-u", help="SSH username on the target.")
145
+ ] = "azureuser",
146
+ keep_key: Annotated[
147
+ bool,
148
+ typer.Option("--keep-key/--shred-key", help="Leave the key on disk after disconnect."),
149
+ ] = False,
150
+ ) -> None:
151
+ """Fetch private key, then SSH into target through Bastion."""
152
+ dest = default_ssh_dir() / f"akf-{secret}"
153
+ try:
154
+ material = fetch_secret(vault, secret)
155
+ key_path = write_private_key(material, dest)
156
+ stdout.print(f"[green]\u2713[/green] key fetched to {key_path}")
157
+ rc = ssh_via_bastion(
158
+ BastionTarget(
159
+ bastion_name=bastion_name,
160
+ bastion_resource_group=bastion_rg,
161
+ target_resource_id=target_id,
162
+ username=username,
163
+ ),
164
+ key_path,
165
+ )
166
+ except AzkvSshFetchError as exc:
167
+ stderr.print(f"[red]error:[/red] {exc}")
168
+ raise typer.Exit(code=2) from exc
169
+ finally:
170
+ if not keep_key:
171
+ try:
172
+ dest.unlink(missing_ok=True)
173
+ stdout.print(f"[dim]shredded {dest}[/dim]")
174
+ except OSError as exc:
175
+ stderr.print(f"[yellow]warning:[/yellow] could not remove {dest}: {exc}")
176
+
177
+ raise typer.Exit(code=rc)
178
+
179
+
180
+ def main() -> None:
181
+ """Entry point for `python -m azkv_ssh_fetch` and the console-script."""
182
+ # Surface azure-identity verbose logs only when AZKV_DEBUG=1
183
+ if os.environ.get("AZKV_DEBUG") == "1":
184
+ import logging
185
+
186
+ logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
187
+ app()
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,23 @@
1
+ """Typed errors raised by the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AzkvSshFetchError(Exception):
7
+ """Base class for all package errors."""
8
+
9
+
10
+ class KeyVaultAccessError(AzkvSshFetchError):
11
+ """Raised when Key Vault is unreachable or caller lacks permission."""
12
+
13
+
14
+ class SecretNotFoundError(AzkvSshFetchError):
15
+ """Raised when the named secret does not exist in the vault."""
16
+
17
+
18
+ class SshKeyWriteError(AzkvSshFetchError):
19
+ """Raised when the SSH key cannot be written to disk safely."""
20
+
21
+
22
+ class BastionInvocationError(AzkvSshFetchError):
23
+ """Raised when `az network bastion` invocation fails."""
@@ -0,0 +1,78 @@
1
+ """Azure Key Vault interactions.
2
+
3
+ Thin wrapper around `azure-keyvault-secrets` + `azure-identity`. Centralizes auth
4
+ and provides typed return values so the CLI layer stays free of SDK boilerplate.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass
11
+
12
+ from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
13
+ from azure.identity import DefaultAzureCredential
14
+ from azure.keyvault.secrets import SecretClient
15
+
16
+ from azkv_ssh_fetch.errors import KeyVaultAccessError, SecretNotFoundError
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SecretSummary:
21
+ """Lightweight view of a Key Vault secret (no value)."""
22
+
23
+ name: str
24
+ enabled: bool
25
+ content_type: str | None
26
+
27
+
28
+ def _client(vault_name: str) -> SecretClient:
29
+ """Build a SecretClient for `vault_name` using DefaultAzureCredential.
30
+
31
+ Auth precedence (azure-identity defaults): env vars, managed identity, az CLI,
32
+ VS Code, Azure PowerShell, interactive browser. The CLI relies on `az login`
33
+ in practice.
34
+ """
35
+ url = f"https://{vault_name}.vault.azure.net"
36
+ return SecretClient(vault_url=url, credential=DefaultAzureCredential())
37
+
38
+
39
+ def list_secrets(vault_name: str) -> Iterator[SecretSummary]:
40
+ """Yield all enabled secrets in the vault.
41
+
42
+ Raises:
43
+ KeyVaultAccessError: caller lacks `list` permission or vault is unreachable.
44
+ """
45
+ client = _client(vault_name)
46
+ try:
47
+ for prop in client.list_properties_of_secrets():
48
+ yield SecretSummary(
49
+ name=prop.name or "",
50
+ enabled=bool(prop.enabled),
51
+ content_type=prop.content_type,
52
+ )
53
+ except HttpResponseError as exc:
54
+ raise KeyVaultAccessError(f"cannot list secrets in {vault_name!r}: {exc.message}") from exc
55
+
56
+
57
+ def fetch_secret(vault_name: str, secret_name: str) -> str:
58
+ """Fetch the value of `secret_name` from `vault_name`.
59
+
60
+ Raises:
61
+ SecretNotFoundError: the secret does not exist (or caller cannot see it).
62
+ KeyVaultAccessError: any other access failure.
63
+ """
64
+ client = _client(vault_name)
65
+ try:
66
+ secret = client.get_secret(secret_name)
67
+ except ResourceNotFoundError as exc:
68
+ raise SecretNotFoundError(
69
+ f"secret {secret_name!r} not found in vault {vault_name!r}"
70
+ ) from exc
71
+ except HttpResponseError as exc:
72
+ raise KeyVaultAccessError(
73
+ f"cannot fetch {secret_name!r} from {vault_name!r}: {exc.message}"
74
+ ) from exc
75
+
76
+ if secret.value is None:
77
+ raise SecretNotFoundError(f"secret {secret_name!r} has no value")
78
+ return secret.value
@@ -0,0 +1,78 @@
1
+ """Local SSH key management: safe writes, mode 0600, atomic replace.
2
+
3
+ Avoids common footguns:
4
+ - Never write keys to world-readable temp dirs
5
+ - Always set mode 0600 BEFORE writing key material
6
+ - Use atomic os.replace so partial writes don't leave half-keys behind
7
+ - Append trailing newline if missing (OpenSSH cares)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import stat
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ from azkv_ssh_fetch.errors import SshKeyWriteError
18
+
19
+ DEFAULT_SSH_DIR = Path.home() / ".ssh"
20
+ KEY_MODE = 0o600
21
+ DIR_MODE = 0o700
22
+
23
+
24
+ def default_ssh_dir() -> Path:
25
+ """Return ~/.ssh, creating it with mode 0700 if missing."""
26
+ DEFAULT_SSH_DIR.mkdir(mode=DIR_MODE, exist_ok=True)
27
+ # Tighten perms even if it already existed with looser bits
28
+ DEFAULT_SSH_DIR.chmod(DIR_MODE)
29
+ return DEFAULT_SSH_DIR
30
+
31
+
32
+ def write_private_key(key_material: str, destination: Path) -> Path:
33
+ """Write `key_material` to `destination` with mode 0600, atomically.
34
+
35
+ Returns the resolved destination Path. Trailing newline is appended if missing.
36
+
37
+ Raises:
38
+ SshKeyWriteError: any IO problem (permissions, full disk, etc.).
39
+ """
40
+ if not key_material:
41
+ raise SshKeyWriteError("empty key material; refusing to write")
42
+
43
+ payload = key_material if key_material.endswith("\n") else key_material + "\n"
44
+
45
+ dest = destination.expanduser().resolve()
46
+ try:
47
+ dest.parent.mkdir(mode=DIR_MODE, parents=True, exist_ok=True)
48
+ # Tighten parent perms if it already existed
49
+ dest.parent.chmod(DIR_MODE)
50
+ except OSError as exc:
51
+ raise SshKeyWriteError(f"cannot prepare {dest.parent}: {exc}") from exc
52
+
53
+ # Write to a sibling tempfile with mode 0600, then atomic replace
54
+ fd, tmp_path_str = tempfile.mkstemp(
55
+ prefix=dest.name + ".",
56
+ suffix=".tmp",
57
+ dir=dest.parent,
58
+ )
59
+ tmp_path = Path(tmp_path_str)
60
+ try:
61
+ os.fchmod(fd, KEY_MODE)
62
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
63
+ fh.write(payload)
64
+ os.replace(tmp_path, dest)
65
+ except OSError as exc:
66
+ tmp_path.unlink(missing_ok=True)
67
+ raise SshKeyWriteError(f"failed to write key to {dest}: {exc}") from exc
68
+
69
+ # Belt-and-suspenders: enforce final mode in case umask interfered earlier
70
+ dest.chmod(KEY_MODE)
71
+ return dest
72
+
73
+
74
+ def assert_safe_mode(path: Path) -> None:
75
+ """Raise if `path` is group/world readable (OpenSSH will reject)."""
76
+ st = path.stat()
77
+ if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
78
+ raise SshKeyWriteError(f"{path} is group/world accessible; chmod 600 required")
File without changes
@@ -0,0 +1,104 @@
1
+ """Tests for the CLI surface. Heavy mocking; no live Azure calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from pytest_mock import MockerFixture
9
+ from typer.testing import CliRunner
10
+
11
+ from azkv_ssh_fetch import __version__
12
+ from azkv_ssh_fetch.cli import app
13
+ from azkv_ssh_fetch.errors import KeyVaultAccessError, SecretNotFoundError
14
+ from azkv_ssh_fetch.keyvault import SecretSummary
15
+
16
+ runner = CliRunner()
17
+
18
+
19
+ def test_version_flag() -> None:
20
+ result = runner.invoke(app, ["--version"])
21
+ assert result.exit_code == 0
22
+ assert __version__ in result.stdout
23
+
24
+
25
+ def test_help_lists_subcommands() -> None:
26
+ result = runner.invoke(app, ["--help"])
27
+ assert result.exit_code == 0
28
+ for cmd in ("list", "fetch", "connect"):
29
+ assert cmd in result.stdout
30
+
31
+
32
+ def test_list_filters_to_ssh_shaped(mocker: MockerFixture) -> None:
33
+ mocker.patch(
34
+ "azkv_ssh_fetch.cli.list_secrets",
35
+ return_value=iter(
36
+ [
37
+ SecretSummary(name="alpha-ssh", enabled=True, content_type=None),
38
+ SecretSummary(name="db-password", enabled=True, content_type=None),
39
+ SecretSummary(name="id_rsa-prod", enabled=True, content_type=None),
40
+ SecretSummary(name="disabled-ssh", enabled=False, content_type=None),
41
+ ]
42
+ ),
43
+ )
44
+ result = runner.invoke(app, ["list", "--vault", "kv-test"])
45
+ assert result.exit_code == 0
46
+ assert "alpha-ssh" in result.stdout
47
+ assert "id_rsa-prod" in result.stdout
48
+ assert "db-password" not in result.stdout
49
+ assert "disabled-ssh" not in result.stdout
50
+
51
+
52
+ def test_list_empty_exits_1(mocker: MockerFixture) -> None:
53
+ mocker.patch("azkv_ssh_fetch.cli.list_secrets", return_value=iter([]))
54
+ result = runner.invoke(app, ["list", "--vault", "kv-test"])
55
+ assert result.exit_code == 1
56
+
57
+
58
+ def test_list_kv_error_exits_2(mocker: MockerFixture) -> None:
59
+ mocker.patch(
60
+ "azkv_ssh_fetch.cli.list_secrets",
61
+ side_effect=KeyVaultAccessError("nope"),
62
+ )
63
+ result = runner.invoke(app, ["list", "--vault", "kv-test"])
64
+ assert result.exit_code == 2
65
+
66
+
67
+ def test_fetch_writes_to_output(tmp_path: Path, mocker: MockerFixture) -> None:
68
+ mocker.patch(
69
+ "azkv_ssh_fetch.cli.fetch_secret",
70
+ return_value="-----BEGIN OPENSSH PRIVATE KEY-----\ncontent\n-----END OPENSSH PRIVATE KEY-----",
71
+ )
72
+ out = tmp_path / "id_test"
73
+ result = runner.invoke(app, ["fetch", "--vault", "kv-test", "my-secret", "--output", str(out)])
74
+ assert result.exit_code == 0, result.stdout
75
+ assert out.exists()
76
+ import stat
77
+
78
+ assert stat.S_IMODE(out.stat().st_mode) == 0o600
79
+
80
+
81
+ def test_fetch_missing_secret_exits_2(tmp_path: Path, mocker: MockerFixture) -> None:
82
+ mocker.patch(
83
+ "azkv_ssh_fetch.cli.fetch_secret",
84
+ side_effect=SecretNotFoundError("nope"),
85
+ )
86
+ result = runner.invoke(
87
+ app,
88
+ ["fetch", "--vault", "kv-test", "ghost", "--output", str(tmp_path / "k")],
89
+ )
90
+ assert result.exit_code == 2
91
+
92
+
93
+ def test_vault_from_env(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None:
94
+ monkeypatch.setenv("AZKV_VAULT", "kv-from-env")
95
+ captured: dict[str, str] = {}
96
+
97
+ def fake_list(vault: str):
98
+ captured["vault"] = vault
99
+ return iter([SecretSummary(name="x-ssh", enabled=True, content_type=None)])
100
+
101
+ mocker.patch("azkv_ssh_fetch.cli.list_secrets", side_effect=fake_list)
102
+ result = runner.invoke(app, ["list"])
103
+ assert result.exit_code == 0
104
+ assert captured["vault"] == "kv-from-env"
@@ -0,0 +1,83 @@
1
+ """Tests for the Key Vault wrapper. SDK is mocked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock
6
+
7
+ import pytest
8
+ from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
9
+ from pytest_mock import MockerFixture
10
+
11
+ from azkv_ssh_fetch import keyvault
12
+ from azkv_ssh_fetch.errors import KeyVaultAccessError, SecretNotFoundError
13
+
14
+
15
+ def _patch_client(mocker: MockerFixture) -> MagicMock:
16
+ """Patch the SecretClient factory and return the mock client."""
17
+ client = MagicMock()
18
+ mocker.patch.object(keyvault, "_client", return_value=client)
19
+ return client
20
+
21
+
22
+ def test_fetch_secret_returns_value(mocker: MockerFixture) -> None:
23
+ client = _patch_client(mocker)
24
+ secret = MagicMock()
25
+ secret.value = "PRIVATE-KEY-CONTENT"
26
+ client.get_secret.return_value = secret
27
+
28
+ value = keyvault.fetch_secret("kv-test", "my-key")
29
+ assert value == "PRIVATE-KEY-CONTENT"
30
+ client.get_secret.assert_called_once_with("my-key")
31
+
32
+
33
+ def test_fetch_secret_missing_raises(mocker: MockerFixture) -> None:
34
+ client = _patch_client(mocker)
35
+ client.get_secret.side_effect = ResourceNotFoundError(message="not found")
36
+
37
+ with pytest.raises(SecretNotFoundError, match="not found"):
38
+ keyvault.fetch_secret("kv-test", "missing")
39
+
40
+
41
+ def test_fetch_secret_http_error_raises_access(mocker: MockerFixture) -> None:
42
+ client = _patch_client(mocker)
43
+ client.get_secret.side_effect = HttpResponseError(message="forbidden")
44
+
45
+ with pytest.raises(KeyVaultAccessError, match="forbidden"):
46
+ keyvault.fetch_secret("kv-test", "any")
47
+
48
+
49
+ def test_fetch_secret_null_value_raises(mocker: MockerFixture) -> None:
50
+ client = _patch_client(mocker)
51
+ secret = MagicMock()
52
+ secret.value = None
53
+ client.get_secret.return_value = secret
54
+
55
+ with pytest.raises(SecretNotFoundError, match="no value"):
56
+ keyvault.fetch_secret("kv-test", "blank")
57
+
58
+
59
+ def test_list_secrets_yields_summaries(mocker: MockerFixture) -> None:
60
+ client = _patch_client(mocker)
61
+ p1 = MagicMock(name="prop1")
62
+ p1.name = "alpha-ssh"
63
+ p1.enabled = True
64
+ p1.content_type = "application/x-pem-file"
65
+ p2 = MagicMock(name="prop2")
66
+ p2.name = "beta"
67
+ p2.enabled = False
68
+ p2.content_type = None
69
+ client.list_properties_of_secrets.return_value = iter([p1, p2])
70
+
71
+ out = list(keyvault.list_secrets("kv-test"))
72
+ assert [s.name for s in out] == ["alpha-ssh", "beta"]
73
+ assert out[0].enabled is True
74
+ assert out[0].content_type == "application/x-pem-file"
75
+ assert out[1].enabled is False
76
+
77
+
78
+ def test_list_secrets_http_error(mocker: MockerFixture) -> None:
79
+ client = _patch_client(mocker)
80
+ client.list_properties_of_secrets.side_effect = HttpResponseError(message="denied")
81
+
82
+ with pytest.raises(KeyVaultAccessError, match="denied"):
83
+ list(keyvault.list_secrets("kv-test"))
@@ -0,0 +1,77 @@
1
+ """Tests for the SSH key writer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from azkv_ssh_fetch.errors import SshKeyWriteError
12
+ from azkv_ssh_fetch.ssh import assert_safe_mode, write_private_key
13
+
14
+ SAMPLE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake-base64-payload-here\n-----END OPENSSH PRIVATE KEY-----"
15
+
16
+
17
+ def test_write_private_key_sets_mode_0600(tmp_path: Path) -> None:
18
+ dest = tmp_path / "ssh" / "id_test"
19
+ written = write_private_key(SAMPLE_KEY, dest)
20
+
21
+ assert written.exists()
22
+ mode = stat.S_IMODE(written.stat().st_mode)
23
+ assert mode == 0o600, f"expected 0600, got {oct(mode)}"
24
+
25
+
26
+ def test_write_private_key_appends_trailing_newline(tmp_path: Path) -> None:
27
+ dest = tmp_path / "id_test"
28
+ write_private_key(SAMPLE_KEY, dest)
29
+ assert dest.read_text(encoding="utf-8").endswith("\n")
30
+
31
+
32
+ def test_write_private_key_preserves_existing_newline(tmp_path: Path) -> None:
33
+ dest = tmp_path / "id_test"
34
+ write_private_key(SAMPLE_KEY + "\n", dest)
35
+ content = dest.read_text(encoding="utf-8")
36
+ assert content.endswith("\n")
37
+ assert not content.endswith("\n\n"), "should not double-newline"
38
+
39
+
40
+ def test_write_private_key_atomic_replace(tmp_path: Path) -> None:
41
+ """A pre-existing key file should be replaced cleanly."""
42
+ dest = tmp_path / "id_test"
43
+ dest.write_text("OLD CONTENT\n", encoding="utf-8")
44
+ os.chmod(dest, 0o600)
45
+
46
+ write_private_key(SAMPLE_KEY, dest)
47
+ assert "OLD CONTENT" not in dest.read_text(encoding="utf-8")
48
+ assert SAMPLE_KEY in dest.read_text(encoding="utf-8")
49
+
50
+
51
+ def test_write_private_key_creates_missing_parent(tmp_path: Path) -> None:
52
+ dest = tmp_path / "nested" / "deeply" / "id_test"
53
+ write_private_key(SAMPLE_KEY, dest)
54
+ assert dest.exists()
55
+ # Parent should be 0700
56
+ parent_mode = stat.S_IMODE(dest.parent.stat().st_mode)
57
+ assert parent_mode == 0o700
58
+
59
+
60
+ def test_write_private_key_empty_raises(tmp_path: Path) -> None:
61
+ with pytest.raises(SshKeyWriteError, match="empty"):
62
+ write_private_key("", tmp_path / "id_test")
63
+
64
+
65
+ def test_assert_safe_mode_passes_for_0600(tmp_path: Path) -> None:
66
+ p = tmp_path / "key"
67
+ p.write_text("x", encoding="utf-8")
68
+ p.chmod(0o600)
69
+ assert_safe_mode(p) # no exception
70
+
71
+
72
+ def test_assert_safe_mode_rejects_world_readable(tmp_path: Path) -> None:
73
+ p = tmp_path / "key"
74
+ p.write_text("x", encoding="utf-8")
75
+ p.chmod(0o644)
76
+ with pytest.raises(SshKeyWriteError, match="group/world"):
77
+ assert_safe_mode(p)