keenv 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.
- keenv-0.1.0/PKG-INFO +184 -0
- keenv-0.1.0/README.md +158 -0
- keenv-0.1.0/pyproject.toml +46 -0
- keenv-0.1.0/src/keenv/__init__.py +0 -0
- keenv-0.1.0/src/keenv/__main__.py +5 -0
- keenv-0.1.0/src/keenv/cli.py +122 -0
- keenv-0.1.0/src/keenv/config.py +155 -0
- keenv-0.1.0/src/keenv/uri.py +68 -0
- keenv-0.1.0/src/keenv/vault.py +83 -0
keenv-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: keenv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run a command with secrets pulled from a KeePass database into its environment
|
|
5
|
+
Author: zombig
|
|
6
|
+
Author-email: zombig <me@zombig.name>
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Dist: pydantic>=2
|
|
20
|
+
Requires-Dist: pykeepass>=4.1
|
|
21
|
+
Requires-Dist: pyyaml>=6
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Project-URL: Issues, https://github.com/oberon-systems/keenv/issues
|
|
24
|
+
Project-URL: Repository, https://github.com/oberon-systems/keenv
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# keenv
|
|
28
|
+
|
|
29
|
+
`keenv` puts secrets from a [KeePass](https://keepass.info/) database into the
|
|
30
|
+
environment of one command and nowhere else. The values never reach a file, an
|
|
31
|
+
`export`, or the shell history: they exist between unlocking the database and
|
|
32
|
+
`exec`ing the command, and the process that held them is replaced.
|
|
33
|
+
|
|
34
|
+
It is the tool the Oberon Systems keyring policy names for local secrets, and
|
|
35
|
+
the way [indech](https://github.com/oberon-systems/indech) hands the Cloudflare
|
|
36
|
+
R2 keys to OpenTofu.
|
|
37
|
+
|
|
38
|
+
## Contents
|
|
39
|
+
|
|
40
|
+
- [Why](#why)
|
|
41
|
+
- [Installation](#installation)
|
|
42
|
+
- [Configuration](#configuration)
|
|
43
|
+
- [Usage](#usage)
|
|
44
|
+
- [How it works](#how-it-works)
|
|
45
|
+
- [Development](#development)
|
|
46
|
+
|
|
47
|
+
## Why
|
|
48
|
+
|
|
49
|
+
The usual ways of getting a secret into a process all leave it somewhere:
|
|
50
|
+
`export` puts it in every child of the shell for the rest of the session, a
|
|
51
|
+
`.env` full of plaintext puts it on disk, and `eval $(something)` puts it in
|
|
52
|
+
the history file as well.
|
|
53
|
+
|
|
54
|
+
`keenv` reads the value out of the database at the moment it is needed, builds
|
|
55
|
+
the environment for exactly one command, and replaces itself with that command.
|
|
56
|
+
There is no `keenv export` and no `keenv eval`, on purpose.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install keenv
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The database itself is read in process through
|
|
65
|
+
[pykeepass](https://github.com/libkeepass/pykeepass), so KeePassXC does not
|
|
66
|
+
have to be installed.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
`keenv` reads two files, both optional. `keenv.yaml` names the database and
|
|
71
|
+
maps environment variables onto entries:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
vault: ~/Dropbox/oberon.kdbx
|
|
75
|
+
keyfile: ~/.keys/oberon.keyx
|
|
76
|
+
|
|
77
|
+
env:
|
|
78
|
+
AWS_ACCESS_KEY_ID:
|
|
79
|
+
entry: Oberon/R2/indech-state
|
|
80
|
+
field: username
|
|
81
|
+
AWS_SECRET_ACCESS_KEY:
|
|
82
|
+
entry: Oberon/R2/indech-state
|
|
83
|
+
field: password
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`.env` is the same mapping in the shape people already write, where a value may
|
|
87
|
+
be a `keenv://` reference instead of a literal:
|
|
88
|
+
|
|
89
|
+
```dotenv
|
|
90
|
+
AWS_ACCESS_KEY_ID=keenv://Oberon/R2/indech-state/username
|
|
91
|
+
AWS_SECRET_ACCESS_KEY=keenv://Oberon/R2/indech-state/password
|
|
92
|
+
TF_LOG=INFO
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A reference is `keenv://<entry path>/<field>`. The last segment is the field
|
|
96
|
+
and everything before it is the path to the entry, so a field whose name
|
|
97
|
+
contains a slash cannot be addressed. `username`, `password`, `url`, `notes`
|
|
98
|
+
and `title` are matched case-insensitively; any other name is looked up as a
|
|
99
|
+
custom attribute, with its spelling preserved.
|
|
100
|
+
|
|
101
|
+
Values that are not references pass through literally. A `#` only starts a
|
|
102
|
+
comment at the beginning of a line, never in the middle of one, because a
|
|
103
|
+
secret may contain it.
|
|
104
|
+
|
|
105
|
+
The layers apply in this order, each one overriding the last:
|
|
106
|
+
|
|
107
|
+
| Layer | Set by |
|
|
108
|
+
| :--- | :--- |
|
|
109
|
+
| `keenv.yaml` | `-c`, default `./keenv.yaml` |
|
|
110
|
+
| `.env` | `-e`, default `./.env` |
|
|
111
|
+
| the database path | `vault:`, then `KEENV_VAULT`, then `--vault` |
|
|
112
|
+
| the key file path | `keyfile:`, then `KEENV_KEYFILE`, then `--keyfile` |
|
|
113
|
+
|
|
114
|
+
A file that is not there is an empty layer, not an error. Having nothing to
|
|
115
|
+
resolve after both layers is an error.
|
|
116
|
+
|
|
117
|
+
`keenv.yaml` is validated against a
|
|
118
|
+
[pydantic](https://docs.pydantic.dev/) model that rejects keys it does not
|
|
119
|
+
know, so `vualt:` is reported as a mistake rather than quietly ignored.
|
|
120
|
+
|
|
121
|
+
## Usage
|
|
122
|
+
|
|
123
|
+
Run a command with the resolved environment:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
keenv run -- tofu -chdir=circuits/live/ramnode/compute plan
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Check that every reference still points at something, without printing any
|
|
130
|
+
value:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
keenv check
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Expected output:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
AWS_ACCESS_KEY_ID keenv://Oberon/R2/indech-state/UserName 20 chars
|
|
140
|
+
AWS_SECRET_ACCESS_KEY keenv://Oberon/R2/indech-state/Password 40 chars
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Point at another database and another mapping:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
keenv run --vault ~/other.kdbx -e deploy.env -- ./deploy.sh
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Exit codes are `0` on success, `1` for anything `keenv` can explain, `2` for a
|
|
150
|
+
usage mistake, and `127` when the command does not exist. Otherwise the exit
|
|
151
|
+
code is the command's own, because the command replaces `keenv`.
|
|
152
|
+
|
|
153
|
+
## How it works
|
|
154
|
+
|
|
155
|
+
1. Both layers are read and merged into one list of variables.
|
|
156
|
+
2. If any of them is a `keenv://` reference, the database is opened once. The
|
|
157
|
+
master password is asked for on `/dev/tty`, never on stdin, so a password
|
|
158
|
+
prompt can never swallow the first line of a pipe. A key file replaces the
|
|
159
|
+
prompt.
|
|
160
|
+
3. Every reference is resolved from that one open database.
|
|
161
|
+
4. The resolved variables are laid over a copy of the current environment and
|
|
162
|
+
handed to `os.execvpe`.
|
|
163
|
+
|
|
164
|
+
Step 4 is what keeps the secrets contained: `execvpe` replaces the process
|
|
165
|
+
image, so nothing that held the values is still running once the command
|
|
166
|
+
starts, and the shell that invoked `keenv` never saw them.
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
make init
|
|
172
|
+
make test
|
|
173
|
+
make lint
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`make init` creates `.venv`, installs the package in editable mode and wires up
|
|
177
|
+
the [pre-commit](https://pre-commit.com/) hooks. The test suite builds its own
|
|
178
|
+
throwaway database in a temporary directory; no test opens a real vault.
|
|
179
|
+
|
|
180
|
+
Commits go through [commitizen](https://commitizen-tools.github.io/commitizen/):
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
.venv/bin/cz commit
|
|
184
|
+
```
|
keenv-0.1.0/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# keenv
|
|
2
|
+
|
|
3
|
+
`keenv` puts secrets from a [KeePass](https://keepass.info/) database into the
|
|
4
|
+
environment of one command and nowhere else. The values never reach a file, an
|
|
5
|
+
`export`, or the shell history: they exist between unlocking the database and
|
|
6
|
+
`exec`ing the command, and the process that held them is replaced.
|
|
7
|
+
|
|
8
|
+
It is the tool the Oberon Systems keyring policy names for local secrets, and
|
|
9
|
+
the way [indech](https://github.com/oberon-systems/indech) hands the Cloudflare
|
|
10
|
+
R2 keys to OpenTofu.
|
|
11
|
+
|
|
12
|
+
## Contents
|
|
13
|
+
|
|
14
|
+
- [Why](#why)
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Configuration](#configuration)
|
|
17
|
+
- [Usage](#usage)
|
|
18
|
+
- [How it works](#how-it-works)
|
|
19
|
+
- [Development](#development)
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
The usual ways of getting a secret into a process all leave it somewhere:
|
|
24
|
+
`export` puts it in every child of the shell for the rest of the session, a
|
|
25
|
+
`.env` full of plaintext puts it on disk, and `eval $(something)` puts it in
|
|
26
|
+
the history file as well.
|
|
27
|
+
|
|
28
|
+
`keenv` reads the value out of the database at the moment it is needed, builds
|
|
29
|
+
the environment for exactly one command, and replaces itself with that command.
|
|
30
|
+
There is no `keenv export` and no `keenv eval`, on purpose.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install keenv
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The database itself is read in process through
|
|
39
|
+
[pykeepass](https://github.com/libkeepass/pykeepass), so KeePassXC does not
|
|
40
|
+
have to be installed.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
`keenv` reads two files, both optional. `keenv.yaml` names the database and
|
|
45
|
+
maps environment variables onto entries:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
vault: ~/Dropbox/oberon.kdbx
|
|
49
|
+
keyfile: ~/.keys/oberon.keyx
|
|
50
|
+
|
|
51
|
+
env:
|
|
52
|
+
AWS_ACCESS_KEY_ID:
|
|
53
|
+
entry: Oberon/R2/indech-state
|
|
54
|
+
field: username
|
|
55
|
+
AWS_SECRET_ACCESS_KEY:
|
|
56
|
+
entry: Oberon/R2/indech-state
|
|
57
|
+
field: password
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`.env` is the same mapping in the shape people already write, where a value may
|
|
61
|
+
be a `keenv://` reference instead of a literal:
|
|
62
|
+
|
|
63
|
+
```dotenv
|
|
64
|
+
AWS_ACCESS_KEY_ID=keenv://Oberon/R2/indech-state/username
|
|
65
|
+
AWS_SECRET_ACCESS_KEY=keenv://Oberon/R2/indech-state/password
|
|
66
|
+
TF_LOG=INFO
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A reference is `keenv://<entry path>/<field>`. The last segment is the field
|
|
70
|
+
and everything before it is the path to the entry, so a field whose name
|
|
71
|
+
contains a slash cannot be addressed. `username`, `password`, `url`, `notes`
|
|
72
|
+
and `title` are matched case-insensitively; any other name is looked up as a
|
|
73
|
+
custom attribute, with its spelling preserved.
|
|
74
|
+
|
|
75
|
+
Values that are not references pass through literally. A `#` only starts a
|
|
76
|
+
comment at the beginning of a line, never in the middle of one, because a
|
|
77
|
+
secret may contain it.
|
|
78
|
+
|
|
79
|
+
The layers apply in this order, each one overriding the last:
|
|
80
|
+
|
|
81
|
+
| Layer | Set by |
|
|
82
|
+
| :--- | :--- |
|
|
83
|
+
| `keenv.yaml` | `-c`, default `./keenv.yaml` |
|
|
84
|
+
| `.env` | `-e`, default `./.env` |
|
|
85
|
+
| the database path | `vault:`, then `KEENV_VAULT`, then `--vault` |
|
|
86
|
+
| the key file path | `keyfile:`, then `KEENV_KEYFILE`, then `--keyfile` |
|
|
87
|
+
|
|
88
|
+
A file that is not there is an empty layer, not an error. Having nothing to
|
|
89
|
+
resolve after both layers is an error.
|
|
90
|
+
|
|
91
|
+
`keenv.yaml` is validated against a
|
|
92
|
+
[pydantic](https://docs.pydantic.dev/) model that rejects keys it does not
|
|
93
|
+
know, so `vualt:` is reported as a mistake rather than quietly ignored.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
Run a command with the resolved environment:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
keenv run -- tofu -chdir=circuits/live/ramnode/compute plan
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Check that every reference still points at something, without printing any
|
|
104
|
+
value:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
keenv check
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Expected output:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
AWS_ACCESS_KEY_ID keenv://Oberon/R2/indech-state/UserName 20 chars
|
|
114
|
+
AWS_SECRET_ACCESS_KEY keenv://Oberon/R2/indech-state/Password 40 chars
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Point at another database and another mapping:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
keenv run --vault ~/other.kdbx -e deploy.env -- ./deploy.sh
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Exit codes are `0` on success, `1` for anything `keenv` can explain, `2` for a
|
|
124
|
+
usage mistake, and `127` when the command does not exist. Otherwise the exit
|
|
125
|
+
code is the command's own, because the command replaces `keenv`.
|
|
126
|
+
|
|
127
|
+
## How it works
|
|
128
|
+
|
|
129
|
+
1. Both layers are read and merged into one list of variables.
|
|
130
|
+
2. If any of them is a `keenv://` reference, the database is opened once. The
|
|
131
|
+
master password is asked for on `/dev/tty`, never on stdin, so a password
|
|
132
|
+
prompt can never swallow the first line of a pipe. A key file replaces the
|
|
133
|
+
prompt.
|
|
134
|
+
3. Every reference is resolved from that one open database.
|
|
135
|
+
4. The resolved variables are laid over a copy of the current environment and
|
|
136
|
+
handed to `os.execvpe`.
|
|
137
|
+
|
|
138
|
+
Step 4 is what keeps the secrets contained: `execvpe` replaces the process
|
|
139
|
+
image, so nothing that held the values is still running once the command
|
|
140
|
+
starts, and the shell that invoked `keenv` never saw them.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
make init
|
|
146
|
+
make test
|
|
147
|
+
make lint
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`make init` creates `.venv`, installs the package in editable mode and wires up
|
|
151
|
+
the [pre-commit](https://pre-commit.com/) hooks. The test suite builds its own
|
|
152
|
+
throwaway database in a temporary directory; no test opens a real vault.
|
|
153
|
+
|
|
154
|
+
Commits go through [commitizen](https://commitizen-tools.github.io/commitizen/):
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
.venv/bin/cz commit
|
|
158
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "keenv"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run a command with secrets pulled from a KeePass database into its environment"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "zombig", email = "me@zombig.name" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydantic>=2",
|
|
12
|
+
"pykeepass>=4.1",
|
|
13
|
+
"PyYAML>=6",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: POSIX",
|
|
27
|
+
"Topic :: Security",
|
|
28
|
+
"Topic :: Utilities",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
keenv = "keenv.cli:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/oberon-systems/keenv"
|
|
36
|
+
Issues = "https://github.com/oberon-systems/keenv/issues"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = ["pytest>=8"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["uv_build>=0.8.13,<0.9.0"]
|
|
46
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The keenv command line: resolve the plan, then hand the process over."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import DEFAULT_CONFIG, DEFAULT_ENV, Binding, Plan, build
|
|
9
|
+
from .uri import Reference
|
|
10
|
+
from .vault import Vault
|
|
11
|
+
|
|
12
|
+
USAGE_ERROR = 2
|
|
13
|
+
NOT_FOUND = 127
|
|
14
|
+
|
|
15
|
+
ACTIONS = (
|
|
16
|
+
('run', 'run a command with the resolved environment'),
|
|
17
|
+
('check', 'resolve every reference and report, without the values'),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog='keenv',
|
|
24
|
+
description='Put KeePass secrets in the environment of one '
|
|
25
|
+
'command and nowhere else.',
|
|
26
|
+
)
|
|
27
|
+
subparsers = parser.add_subparsers(dest='action', required=True)
|
|
28
|
+
|
|
29
|
+
for action, help_text in ACTIONS:
|
|
30
|
+
sub = subparsers.add_parser(action, help=help_text)
|
|
31
|
+
sub.add_argument(
|
|
32
|
+
'-c', '--config', type=Path, default=DEFAULT_CONFIG,
|
|
33
|
+
help=f'keenv.yaml to read (default: {DEFAULT_CONFIG})',
|
|
34
|
+
)
|
|
35
|
+
sub.add_argument(
|
|
36
|
+
'-e', '--env', type=Path, default=DEFAULT_ENV,
|
|
37
|
+
help=f'.env to read (default: {DEFAULT_ENV})',
|
|
38
|
+
)
|
|
39
|
+
sub.add_argument(
|
|
40
|
+
'--vault', type=Path, default=None,
|
|
41
|
+
help='database, overriding keenv.yaml and KEENV_VAULT',
|
|
42
|
+
)
|
|
43
|
+
sub.add_argument(
|
|
44
|
+
'--keyfile', type=Path, default=None,
|
|
45
|
+
help='key file for the database',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _split_command(argv: list[str]) -> tuple[list[str], list[str]]:
|
|
52
|
+
if '--' not in argv:
|
|
53
|
+
return argv, []
|
|
54
|
+
index = argv.index('--')
|
|
55
|
+
return argv[:index], argv[index + 1:]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve(plan: Plan) -> dict[str, str]:
|
|
59
|
+
if not plan.bindings:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
'nothing to resolve: no env in keenv.yaml and no .env',
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
vault = None
|
|
65
|
+
if any(isinstance(v, Reference) for v in plan.bindings.values()):
|
|
66
|
+
if plan.settings.vault is None:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
'no vault: name it in keenv.yaml, in KEENV_VAULT '
|
|
69
|
+
'or with --vault',
|
|
70
|
+
)
|
|
71
|
+
vault = Vault(plan.settings.vault, plan.settings.keyfile)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: vault.field(value) if isinstance(value, Reference) else value
|
|
75
|
+
for name, value in plan.bindings.items()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _describe(name: str, binding: Binding, value: str) -> str:
|
|
80
|
+
source = str(binding) if isinstance(binding, Reference) else 'literal'
|
|
81
|
+
return f'{name:<28} {source:<52} {len(value)} chars'
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _check(plan: Plan) -> int:
|
|
85
|
+
resolved = _resolve(plan)
|
|
86
|
+
for name, binding in plan.bindings.items():
|
|
87
|
+
print(_describe(name, binding, resolved[name]))
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: list[str] | None = None) -> int:
|
|
92
|
+
"""Entry point. Returns an exit code; `run` never returns at all."""
|
|
93
|
+
given = list(sys.argv[1:] if argv is None else argv)
|
|
94
|
+
given, command = _split_command(given)
|
|
95
|
+
options = _parser().parse_args(given)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
plan = build(
|
|
99
|
+
options.config, options.env, options.vault, options.keyfile,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if options.action == 'check':
|
|
103
|
+
return _check(plan)
|
|
104
|
+
|
|
105
|
+
if not command:
|
|
106
|
+
print(
|
|
107
|
+
'keenv run needs a command: keenv run -- tofu plan',
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
return USAGE_ERROR
|
|
111
|
+
|
|
112
|
+
environment = dict(os.environ)
|
|
113
|
+
environment.update(_resolve(plan))
|
|
114
|
+
os.execvpe(command[0], command, environment)
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
print(f'keenv: {exc}', file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
print(f'keenv: command not found: {command[0]}', file=sys.stderr)
|
|
120
|
+
return NOT_FOUND
|
|
121
|
+
|
|
122
|
+
return 0
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Where the variables come from: keenv.yaml and .env, merged into a plan."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
|
10
|
+
|
|
11
|
+
from .uri import Reference, from_entry, is_reference, parse
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG = Path('keenv.yaml')
|
|
14
|
+
DEFAULT_ENV = Path('.env')
|
|
15
|
+
|
|
16
|
+
# `export ` is accepted so a .env can also be sourced by hand. An inline `#`
|
|
17
|
+
# is not a comment: a secret may legitimately contain one.
|
|
18
|
+
ENV_LINE = re.compile(
|
|
19
|
+
r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Binding = Reference | str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EntrySpec(BaseModel):
|
|
26
|
+
"""One variable in keenv.yaml: which entry, and which field of it."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra='forbid')
|
|
29
|
+
|
|
30
|
+
entry: str
|
|
31
|
+
field: str
|
|
32
|
+
|
|
33
|
+
@field_validator('entry', 'field')
|
|
34
|
+
@classmethod
|
|
35
|
+
def _not_blank(cls, value: str) -> str:
|
|
36
|
+
if not value.strip():
|
|
37
|
+
raise ValueError('must not be empty')
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigFile(BaseModel):
|
|
42
|
+
"""The schema of keenv.yaml. Unknown keys are typos, so they fail."""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(extra='forbid')
|
|
45
|
+
|
|
46
|
+
vault: Path | None = None
|
|
47
|
+
keyfile: Path | None = None
|
|
48
|
+
env: dict[str, EntrySpec] = {}
|
|
49
|
+
|
|
50
|
+
@field_validator('vault', 'keyfile')
|
|
51
|
+
@classmethod
|
|
52
|
+
def _expand(cls, value: Path | None) -> Path | None:
|
|
53
|
+
return value.expanduser() if value else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Settings(NamedTuple):
|
|
57
|
+
"""Which database to open and how."""
|
|
58
|
+
|
|
59
|
+
vault: Path | None
|
|
60
|
+
keyfile: Path | None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Plan(NamedTuple):
|
|
64
|
+
"""The database, and what to put in the environment."""
|
|
65
|
+
|
|
66
|
+
settings: Settings
|
|
67
|
+
bindings: dict[str, Binding]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _expand(value: str | None) -> Path | None:
|
|
71
|
+
return Path(value).expanduser() if value else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _unquote(value: str) -> str:
|
|
75
|
+
quoted = len(value) >= 2 and value[0] == value[-1]
|
|
76
|
+
return value[1:-1] if quoted and value[0] in ('"', "'") else value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _explain(path: Path, error: ValidationError) -> str:
|
|
80
|
+
lines = [
|
|
81
|
+
' {}: {}'.format(
|
|
82
|
+
'.'.join(str(part) for part in item['loc']) or '<root>',
|
|
83
|
+
item['msg'],
|
|
84
|
+
)
|
|
85
|
+
for item in error.errors()
|
|
86
|
+
]
|
|
87
|
+
return '\n'.join([f'{path}:', *lines])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_config(path: Path) -> Plan:
|
|
91
|
+
"""Read a keenv.yaml. A missing file is an empty layer, not an error."""
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
return Plan(Settings(None, None), {})
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
document = yaml.safe_load(path.read_text(encoding='utf-8')) or {}
|
|
97
|
+
except yaml.YAMLError as exc:
|
|
98
|
+
raise ValueError(f'{path}: {exc}') from exc
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
config = ConfigFile.model_validate(document)
|
|
102
|
+
except ValidationError as exc:
|
|
103
|
+
raise ValueError(_explain(path, exc)) from exc
|
|
104
|
+
|
|
105
|
+
bindings: dict[str, Binding] = {
|
|
106
|
+
name: from_entry(spec.entry, spec.field)
|
|
107
|
+
for name, spec in config.env.items()
|
|
108
|
+
}
|
|
109
|
+
return Plan(Settings(config.vault, config.keyfile), bindings)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_env(path: Path) -> dict[str, Binding]:
|
|
113
|
+
"""Read a .env. keenv:// values resolve, everything else is literal."""
|
|
114
|
+
if not path.is_file():
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
bindings: dict[str, Binding] = {}
|
|
118
|
+
text = path.read_text(encoding='utf-8')
|
|
119
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
120
|
+
if not line.strip() or line.lstrip().startswith('#'):
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
match = ENV_LINE.match(line)
|
|
124
|
+
if not match:
|
|
125
|
+
raise ValueError(f'{path}:{number}: not a NAME=value line')
|
|
126
|
+
|
|
127
|
+
name, value = match.group(1), _unquote(match.group(2))
|
|
128
|
+
try:
|
|
129
|
+
bindings[name] = parse(value) if is_reference(value) else value
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
raise ValueError(f'{path}:{number}: {exc}') from exc
|
|
132
|
+
|
|
133
|
+
return bindings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build(
|
|
137
|
+
config_path: Path,
|
|
138
|
+
env_path: Path,
|
|
139
|
+
vault: Path | None = None,
|
|
140
|
+
keyfile: Path | None = None,
|
|
141
|
+
) -> Plan:
|
|
142
|
+
"""Merge both layers. .env beats keenv.yaml, the flags beat both."""
|
|
143
|
+
plan = load_config(config_path)
|
|
144
|
+
bindings = dict(plan.bindings)
|
|
145
|
+
bindings.update(load_env(env_path))
|
|
146
|
+
|
|
147
|
+
settings = Settings(
|
|
148
|
+
vault
|
|
149
|
+
or _expand(os.environ.get('KEENV_VAULT'))
|
|
150
|
+
or plan.settings.vault,
|
|
151
|
+
keyfile
|
|
152
|
+
or _expand(os.environ.get('KEENV_KEYFILE'))
|
|
153
|
+
or plan.settings.keyfile,
|
|
154
|
+
)
|
|
155
|
+
return Plan(settings, bindings)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The `keenv://` reference: which entry and field a variable comes from."""
|
|
2
|
+
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
SCHEME = 'keenv://'
|
|
6
|
+
|
|
7
|
+
# KeePass spells the built-in fields this way; anything else is a custom
|
|
8
|
+
# attribute and keeps whatever spelling the reference used.
|
|
9
|
+
STANDARD_FIELDS = {
|
|
10
|
+
'username': 'UserName',
|
|
11
|
+
'user': 'UserName',
|
|
12
|
+
'password': 'Password',
|
|
13
|
+
'url': 'URL',
|
|
14
|
+
'notes': 'Notes',
|
|
15
|
+
'title': 'Title',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Reference(NamedTuple):
|
|
20
|
+
"""An entry path inside the database plus the field to read from it."""
|
|
21
|
+
|
|
22
|
+
path: tuple[str, ...]
|
|
23
|
+
field: str
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return SCHEME + '/'.join((*self.path, self.field))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_field(field: str) -> str:
|
|
30
|
+
"""Map a field onto its KeePass spelling, leaving custom ones be."""
|
|
31
|
+
field = field.strip()
|
|
32
|
+
return STANDARD_FIELDS.get(field.lower(), field)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_reference(value: str) -> bool:
|
|
36
|
+
"""Whether a value is a keenv:// reference rather than a literal."""
|
|
37
|
+
return value.startswith(SCHEME)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse(value: str) -> Reference:
|
|
41
|
+
"""Parse `keenv://Group/Sub/Entry/field` into a Reference.
|
|
42
|
+
|
|
43
|
+
The last segment is the field and everything before it is the path to the
|
|
44
|
+
entry, so a field name containing a slash cannot be addressed this way.
|
|
45
|
+
"""
|
|
46
|
+
if not is_reference(value):
|
|
47
|
+
raise ValueError(f'not a keenv reference: {value!r}')
|
|
48
|
+
|
|
49
|
+
segments = value[len(SCHEME):].split('/')
|
|
50
|
+
if any(not segment for segment in segments):
|
|
51
|
+
raise ValueError(f'empty segment in reference: {value!r}')
|
|
52
|
+
if len(segments) < 2:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f'reference needs an entry path and a field: {value!r}',
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return Reference(tuple(segments[:-1]), normalize_field(segments[-1]))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def from_entry(entry: str, field: str) -> Reference:
|
|
61
|
+
"""Build a Reference from the `entry:`/`field:` pair keenv.yaml uses."""
|
|
62
|
+
segments = [segment for segment in entry.split('/') if segment]
|
|
63
|
+
if not segments:
|
|
64
|
+
raise ValueError(f'entry path is empty: {entry!r}')
|
|
65
|
+
if not field.strip():
|
|
66
|
+
raise ValueError(f'field is empty for entry {entry!r}')
|
|
67
|
+
|
|
68
|
+
return Reference(tuple(segments), normalize_field(field))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Opening the KeePass database and reading single fields out of it."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pykeepass import PyKeePass
|
|
7
|
+
from pykeepass.exceptions import CredentialsError
|
|
8
|
+
|
|
9
|
+
from .uri import Reference
|
|
10
|
+
|
|
11
|
+
# KeePass field name -> the attribute pykeepass exposes it under.
|
|
12
|
+
PROPERTIES = {
|
|
13
|
+
'UserName': 'username',
|
|
14
|
+
'Password': 'password',
|
|
15
|
+
'URL': 'url',
|
|
16
|
+
'Notes': 'notes',
|
|
17
|
+
'Title': 'title',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def prompt_password(vault: Path) -> str:
|
|
22
|
+
"""Ask for the master password on the terminal, never on stdin.
|
|
23
|
+
|
|
24
|
+
Falling back to stdin would silently eat the first line of a pipe, so a
|
|
25
|
+
session without a terminal is an error the caller has to fix.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
with open('/dev/tty', 'w+', encoding='utf-8') as tty:
|
|
29
|
+
prompt = f'Master password for {vault}: '
|
|
30
|
+
return getpass.getpass(prompt, stream=tty)
|
|
31
|
+
except OSError as exc:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f'no terminal to ask for the master password of {vault}; '
|
|
34
|
+
'run keenv from a terminal or point it at a key file',
|
|
35
|
+
) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Vault:
|
|
39
|
+
"""A KeePass database, opened once and read many times."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, path: Path, keyfile: Path | None = None,
|
|
42
|
+
password: str | None = None) -> None:
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
raise ValueError(f'vault not found: {path}')
|
|
45
|
+
if keyfile is not None and not keyfile.is_file():
|
|
46
|
+
raise ValueError(f'key file not found: {keyfile}')
|
|
47
|
+
if password is None and keyfile is None:
|
|
48
|
+
password = prompt_password(path)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
self._database = PyKeePass(
|
|
52
|
+
str(path),
|
|
53
|
+
password=password,
|
|
54
|
+
keyfile=str(keyfile) if keyfile else None,
|
|
55
|
+
)
|
|
56
|
+
except CredentialsError as exc:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f'{path}: wrong master password or key file',
|
|
59
|
+
) from exc
|
|
60
|
+
self.path = path
|
|
61
|
+
|
|
62
|
+
def field(self, reference: Reference) -> str:
|
|
63
|
+
"""Read one field of one entry, or explain which half is missing."""
|
|
64
|
+
entry = self._database.find_entries(
|
|
65
|
+
path=list(reference.path), first=True,
|
|
66
|
+
)
|
|
67
|
+
if entry is None:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f'{self.path}: no entry at {"/".join(reference.path)}',
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
attribute = PROPERTIES.get(reference.field)
|
|
73
|
+
if attribute is not None:
|
|
74
|
+
value = getattr(entry, attribute)
|
|
75
|
+
else:
|
|
76
|
+
value = entry.get_custom_property(reference.field)
|
|
77
|
+
|
|
78
|
+
if value is None:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f'{self.path}: entry {"/".join(reference.path)} '
|
|
81
|
+
f'has no {reference.field} field',
|
|
82
|
+
)
|
|
83
|
+
return str(value)
|