pykeepassgate 0.0.1__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,34 @@
1
+ # Anchored with a leading slash, so that a rule meant for one place cannot quietly match another
2
+ # somewhere else. What a project writes is ignored beside that project rather than from here.
3
+ #
4
+ # `__pycache__/` is the one exception and is deliberately recursive, because Python writes it
5
+ # wherever a package happens to live.
6
+
7
+ /.tmp/
8
+
9
+ # What the gallery fixtures draw, for somebody to look at and nobody to keep. Rewritten on every
10
+ # run and never published: `docs/images` is the set that is committed, because those are the ones a
11
+ # reader of the documentation sees.
12
+ /.gallery/
13
+ /.vs/
14
+
15
+ # What the coverage collector writes, one report per test assembly per run.
16
+ /.cov/
17
+
18
+ # The KeePass this repository pins, fetched by `tools/KeePass.ps1` and named after its
19
+ # version. Provided rather than committed: it is somebody else's program.
20
+ /.keepass/
21
+
22
+ /node_modules/
23
+ /scratchpad/
24
+
25
+ # `uv` builds this from `uv.lock`, which is committed.
26
+ /.venv/
27
+ /.pytest_cache/
28
+ /.mypy_cache/
29
+ /.ruff_cache/
30
+ /.coverage
31
+ __pycache__/
32
+
33
+ # Per machine, and not everybody's business: the checked-in `.claude/settings.json` is.
34
+ /.claude/settings.local.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Freund
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: pykeepassgate
3
+ Version: 0.0.1
4
+ Summary: The reference client for KeePassGate: pairing, and everything the gate serves.
5
+ Project-URL: Homepage, https://github.com/mfrnd/KeePassGate
6
+ Author: KeePassGate contributors
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: keepass,keepassgate,mtls,password-manager
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: cryptography>=42
12
+ Description-Content-Type: text/markdown
13
+
14
+ # PyKeePassGate
15
+
16
+ The reference client for [KeePassGate](https://github.com/mfrnd/KeePassGate), a KeePass 2.x plugin
17
+ that serves a small specification-defined API over mutual TLS so that automation and AI agents can
18
+ each be given a narrow, revocable, audited slice of a database.
19
+
20
+ **Reference implementation** means the code here is the second copy of the specification. Where the
21
+ plugin and this disagree, one of them is wrong and a test should already have said so. It also means
22
+ what is missing is a statement: a route with no method here is a route the gate does not serve.
23
+
24
+ ## Install
25
+
26
+ ```shell
27
+ uv add pykeepassgate
28
+ ```
29
+
30
+ One dependency, `cryptography`, because the gate hands over a PKCS#12 and Python's `ssl` reads PEM.
31
+ Everything else, the command line included, is the standard library.
32
+
33
+ ## Pair
34
+
35
+ The operator presses **Renew invite** on the **KPG** tab of the database's settings dialog and sends
36
+ the invitation over a second channel. What they see while doing it is
37
+ [`docs/pairing.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pairing.md), and worked examples of everything below are
38
+ [`docs/pykeepassgate.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pykeepassgate.md). It is spent by the first client to use it
39
+ and it does not survive KeePass closing.
40
+
41
+ ```shell
42
+ pykeepassgate pair --port 45678 --name agent-invented --profile-name reader
43
+ ```
44
+
45
+ Leave `--invitation` off and the token is read from standard input, so it stays out of the shell
46
+ history and out of the process list.
47
+
48
+ The same thing from a program:
49
+
50
+ ```python
51
+ from pykeepassgate import api
52
+
53
+ credentials = api.pair(
54
+ port=45678,
55
+ invitation="...",
56
+ name="agent-invented",
57
+ profiles=["reader"],
58
+ )
59
+
60
+ print(credentials.profiles) # what the operator approved, not what was asked for
61
+ ```
62
+
63
+ ## Use
64
+
65
+ ```python
66
+ from pykeepassgate import GateClient
67
+
68
+ client = GateClient.open()
69
+ print(client.status())
70
+ ```
71
+
72
+ ```shell
73
+ pykeepassgate status
74
+ pykeepassgate whoami
75
+ ```
76
+
77
+ ## What to catch
78
+
79
+ Every exception says what to do next rather than what went wrong, because that is the part a caller
80
+ has to decide.
81
+
82
+ | Exception | What it means, and what to do |
83
+ | --- | --- |
84
+ | `DatabaseLocked` | The database is locked. Wait, and try again. |
85
+ | `GateBusy` | The gate cannot write right now, usually because a group dialog is open. Retry. |
86
+ | `NotAdmitted` | This database holds no record for this certificate. It was never admitted, or it was revoked. Pair again. |
87
+ | `ProofRefused` | During pairing: the token is wrong, or whatever answered is not the KeePass whose operator holds it. Stop. |
88
+ | `PairingRefused` | The operator said no. The invitation is spent, so ask for another. |
89
+ | `PairingUnanswered` | Nobody answered the dialog. The invitation is **not** spent, so this can be tried again. |
90
+ | `RouteRefused` | Everything else the gate refuses, carrying the status and the one word it used. A `404` is deliberately both "no such node" and "not yours", so a client cannot map a database by asking: if the node should be there, the rule is too narrow and the operator has to widen it. |
91
+ | `MalformedToken` | The text handed over is not an invitation, so nothing was sent. Check what was pasted. |
92
+ | `ProtocolError` | Something on the wire is not what the protocol allows: an answer this client cannot read, or a request it will not send. Do not retry an answer it cannot read: the two halves disagree about the wire, which `tests/pykeepassgate/test_protocol.py` exists to stop. |
93
+ | `StorageError` | What is stored for this client will not read, or could not be written. Look at the directory `status` names. |
94
+ | `TransportError` | Nothing is listening, or the handshake failed. |
95
+
96
+ ## What it does not do
97
+
98
+ **It does not open a `.kdbx`.** There is no file and no master password on this side of the API, by
99
+ design. What that means for anybody expecting a `pykeepass` object is measured on the
100
+ `spikes/pykeepass-compatibility` branch: 23 of its 56 public members are servable over the routes the
101
+ gate already has, and the object itself is not one of them.
102
+
103
+ **It defends nothing against local code execution as the user.** The client's key is on the disk,
104
+ because a client has to still be the same client tomorrow. The whole design concedes this
105
+ throughout, and so does this.
@@ -0,0 +1,141 @@
1
+ [project]
2
+ name = "pykeepassgate"
3
+ version = "0.0.1"
4
+ description = "The reference client for KeePassGate: pairing, and everything the gate serves."
5
+ readme = "src/pykeepassgate/README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "KeePassGate contributors" }]
9
+ keywords = ["keepass", "keepassgate", "mtls", "password-manager"]
10
+
11
+ # One dependency, and it is the one that cannot be written here. `cryptography` parses the PKCS#12
12
+ # the gate hands over and writes the PEM that `ssl` can load; the standard library reads neither.
13
+ # Everything else, including the command line, is the standard library on purpose: this is the
14
+ # reference implementation, so what it needs is what the protocol needs.
15
+ dependencies = ["cryptography>=42"]
16
+
17
+ [project.scripts]
18
+ pykeepassgate = "pykeepassgate.cli:main"
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/mfrnd/KeePassGate"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/pykeepassgate"]
29
+
30
+ # The sdist default is everything the version control does not ignore, which here is the whole
31
+ # repository: the plugin's C#, its tests, and every design document, inside a package whose whole
32
+ # claim is that it is one dependency and the standard library. Naming the same directory is what
33
+ # stops that.
34
+ [tool.hatch.build.targets.sdist]
35
+ only-include = ["src/pykeepassgate", "LICENSE"]
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "mypy>=1.11",
40
+ "pre-commit>=4",
41
+ # Only the test that holds this client to the plugin's routing table reads it, and the plugin
42
+ # writes that table in YAML because its OpenAPI reader ships a YAML parser either way.
43
+ "pyyaml>=6",
44
+ "types-pyyaml>=6",
45
+ "pytest>=8",
46
+ "pytest-cov>=5",
47
+ "ruff>=0.6",
48
+ ]
49
+
50
+ # ---------------------------------------------------------------------------------------------
51
+ # The checks. One config for one repository, at the root, beside the two that check the prose.
52
+ # ---------------------------------------------------------------------------------------------
53
+
54
+ [tool.ruff]
55
+ line-length = 100
56
+ target-version = "py311"
57
+ src = ["src", "tests/pykeepassgate"]
58
+
59
+ [tool.ruff.lint]
60
+ # The default set plus what catches real mistakes in a client that talks to a password manager:
61
+ # bugbear for the traps, security for the ones that matter here, and the import and comprehension
62
+ # rules because they are free.
63
+ select = [
64
+ "E", "W", # pycodestyle
65
+ "F", # pyflakes
66
+ "I", # import order
67
+ "B", # bugbear
68
+ "C4", # comprehensions
69
+ "UP", # pyupgrade
70
+ "S", # bandit
71
+ "RUF", # ruff's own
72
+ "SIM", # simplify
73
+ "PTH", # pathlib over os.path
74
+ "RET", # returns
75
+ "ARG", # unused arguments
76
+ "N", # naming
77
+ "D", # docstrings
78
+ ]
79
+
80
+ ignore = [
81
+ # A one-line summary is required; the blank line after it and the mood of the first word are
82
+ # not, because this project's comments are sentences rather than a docstring format.
83
+ "D203", "D212", "D401", "D407", "D413",
84
+
85
+ # N818: an exception class should be named `...Error`. Every one of these derives from
86
+ # `GateError`, which is, and the names are chosen to read at the place they are caught:
87
+ # `except DatabaseLocked` says what to do about it and `except DatabaseLockedError` does not.
88
+ # The guideline exists so a reader can tell an exception from a value type, and nothing in
89
+ # `errors.py` is a value. This is the same call, for the same reason, that `CA1710` gets on the
90
+ # plugin's side of the protocol.
91
+ "N818",
92
+ ]
93
+
94
+ [tool.ruff.lint.per-file-ignores]
95
+ # A test says what it does in its name, and it asserts, which `S101` is about. A fake gate is
96
+ # handed every argument whether it reads them or not, which is `ARG`. And a test vector that looks
97
+ # like a credential is an input written down beside its expected output, which is `S105` and its
98
+ # siblings: the whole point of `test_protocol.py` is that these values are public and reproducible.
99
+ "tests/**/*.py" = [
100
+ "S101", "S105", "S106", "S107",
101
+ "D100", "D101", "D102", "D103", "D104", "D107",
102
+ "ARG001", "ARG002", "ARG005",
103
+ ]
104
+
105
+ [tool.ruff.lint.isort]
106
+ # `conftest` is imported by name because the test directory is not a package: making it one would
107
+ # shadow the package under test, which is called the same thing.
108
+ known-first-party = ["pykeepassgate", "conftest"]
109
+
110
+ [tool.ruff.lint.pydocstyle]
111
+ convention = "pep257"
112
+
113
+ [tool.mypy]
114
+ python_version = "3.11"
115
+ files = ["src/pykeepassgate", "tests/pykeepassgate"]
116
+ strict = true
117
+ # A reference implementation says what it means. These three turn "it type checks" into "it says
118
+ # what it is": no silent `Any` crossing a boundary, no untyped call slipping through, and a warning
119
+ # when an annotation turns out to be unnecessary.
120
+ warn_unreachable = true
121
+ warn_return_any = true
122
+ warn_unused_ignores = true
123
+
124
+
125
+ [tool.pytest.ini_options]
126
+ testpaths = ["tests/pykeepassgate"]
127
+ addopts = "--strict-markers --strict-config"
128
+
129
+ [tool.coverage.run]
130
+ source = ["src/pykeepassgate"]
131
+ branch = true
132
+
133
+ [tool.coverage.report]
134
+ # The client is the reference implementation, so an untested path in it is a paragraph of the
135
+ # specification nobody has run.
136
+ fail_under = 90
137
+ show_missing = true
138
+ exclude_also = [
139
+ "if TYPE_CHECKING:",
140
+ "raise NotImplementedError",
141
+ ]
@@ -0,0 +1,92 @@
1
+ # PyKeePassGate
2
+
3
+ The reference client for [KeePassGate](https://github.com/mfrnd/KeePassGate), a KeePass 2.x plugin
4
+ that serves a small specification-defined API over mutual TLS so that automation and AI agents can
5
+ each be given a narrow, revocable, audited slice of a database.
6
+
7
+ **Reference implementation** means the code here is the second copy of the specification. Where the
8
+ plugin and this disagree, one of them is wrong and a test should already have said so. It also means
9
+ what is missing is a statement: a route with no method here is a route the gate does not serve.
10
+
11
+ ## Install
12
+
13
+ ```shell
14
+ uv add pykeepassgate
15
+ ```
16
+
17
+ One dependency, `cryptography`, because the gate hands over a PKCS#12 and Python's `ssl` reads PEM.
18
+ Everything else, the command line included, is the standard library.
19
+
20
+ ## Pair
21
+
22
+ The operator presses **Renew invite** on the **KPG** tab of the database's settings dialog and sends
23
+ the invitation over a second channel. What they see while doing it is
24
+ [`docs/pairing.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pairing.md), and worked examples of everything below are
25
+ [`docs/pykeepassgate.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pykeepassgate.md). It is spent by the first client to use it
26
+ and it does not survive KeePass closing.
27
+
28
+ ```shell
29
+ pykeepassgate pair --port 45678 --name agent-invented --profile-name reader
30
+ ```
31
+
32
+ Leave `--invitation` off and the token is read from standard input, so it stays out of the shell
33
+ history and out of the process list.
34
+
35
+ The same thing from a program:
36
+
37
+ ```python
38
+ from pykeepassgate import api
39
+
40
+ credentials = api.pair(
41
+ port=45678,
42
+ invitation="...",
43
+ name="agent-invented",
44
+ profiles=["reader"],
45
+ )
46
+
47
+ print(credentials.profiles) # what the operator approved, not what was asked for
48
+ ```
49
+
50
+ ## Use
51
+
52
+ ```python
53
+ from pykeepassgate import GateClient
54
+
55
+ client = GateClient.open()
56
+ print(client.status())
57
+ ```
58
+
59
+ ```shell
60
+ pykeepassgate status
61
+ pykeepassgate whoami
62
+ ```
63
+
64
+ ## What to catch
65
+
66
+ Every exception says what to do next rather than what went wrong, because that is the part a caller
67
+ has to decide.
68
+
69
+ | Exception | What it means, and what to do |
70
+ | --- | --- |
71
+ | `DatabaseLocked` | The database is locked. Wait, and try again. |
72
+ | `GateBusy` | The gate cannot write right now, usually because a group dialog is open. Retry. |
73
+ | `NotAdmitted` | This database holds no record for this certificate. It was never admitted, or it was revoked. Pair again. |
74
+ | `ProofRefused` | During pairing: the token is wrong, or whatever answered is not the KeePass whose operator holds it. Stop. |
75
+ | `PairingRefused` | The operator said no. The invitation is spent, so ask for another. |
76
+ | `PairingUnanswered` | Nobody answered the dialog. The invitation is **not** spent, so this can be tried again. |
77
+ | `RouteRefused` | Everything else the gate refuses, carrying the status and the one word it used. A `404` is deliberately both "no such node" and "not yours", so a client cannot map a database by asking: if the node should be there, the rule is too narrow and the operator has to widen it. |
78
+ | `MalformedToken` | The text handed over is not an invitation, so nothing was sent. Check what was pasted. |
79
+ | `ProtocolError` | Something on the wire is not what the protocol allows: an answer this client cannot read, or a request it will not send. Do not retry an answer it cannot read: the two halves disagree about the wire, which `tests/pykeepassgate/test_protocol.py` exists to stop. |
80
+ | `StorageError` | What is stored for this client will not read, or could not be written. Look at the directory `status` names. |
81
+ | `TransportError` | Nothing is listening, or the handshake failed. |
82
+
83
+ ## What it does not do
84
+
85
+ **It does not open a `.kdbx`.** There is no file and no master password on this side of the API, by
86
+ design. What that means for anybody expecting a `pykeepass` object is measured on the
87
+ `spikes/pykeepass-compatibility` branch: 23 of its 56 public members are servable over the routes the
88
+ gate already has, and the object itself is not one of them.
89
+
90
+ **It defends nothing against local code execution as the user.** The client's key is on the disk,
91
+ because a client has to still be the same client tomorrow. The whole design concedes this
92
+ throughout, and so does this.
@@ -0,0 +1,57 @@
1
+ """PyKeePassGate: the reference client for KeePassGate.
2
+
3
+ Two halves, and the split is deliberate. `pykeepassgate.api` is the library: pairing, the paired
4
+ client, and the pieces of the protocol on their own so that each can be read and tested without a
5
+ KeePass in the room. `pykeepassgate.cli` is a command line built on that library and nothing else,
6
+ so anything the command line can do, a program can do the same way.
7
+
8
+ Being the reference implementation means the code is the second copy of the specification, so what
9
+ it does not do is a statement too: a route with no method here is a route the gate does not serve.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .api import (
15
+ Credentials,
16
+ DatabaseLocked,
17
+ Exchange,
18
+ GateBusy,
19
+ GateClient,
20
+ GateError,
21
+ MalformedToken,
22
+ NotAdmitted,
23
+ Pairing,
24
+ PairingError,
25
+ PairingRefused,
26
+ PairingUnanswered,
27
+ ProofRefused,
28
+ ProtocolError,
29
+ RouteRefused,
30
+ Status,
31
+ TransportError,
32
+ pair,
33
+ )
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "Credentials",
39
+ "DatabaseLocked",
40
+ "Exchange",
41
+ "GateBusy",
42
+ "GateClient",
43
+ "GateError",
44
+ "MalformedToken",
45
+ "NotAdmitted",
46
+ "Pairing",
47
+ "PairingError",
48
+ "PairingRefused",
49
+ "PairingUnanswered",
50
+ "ProofRefused",
51
+ "ProtocolError",
52
+ "RouteRefused",
53
+ "Status",
54
+ "TransportError",
55
+ "__version__",
56
+ "pair",
57
+ ]
@@ -0,0 +1,96 @@
1
+ """The library, gathered into one name so that a program imports one thing.
2
+
3
+ Everything a caller needs is re-exported here: pairing, the paired client, the credentials on disk,
4
+ and the exceptions that say what to do next. The modules underneath stay importable, because the
5
+ pieces of the protocol are worth reading on their own, but nothing outside this package has to know
6
+ which module a name lives in.
7
+
8
+ The one thing `api` adds rather than re-exports is `pair`, which is the whole exchange as one call.
9
+ It exists because the two-connection shape is the protocol's business rather than the caller's, and
10
+ a reference implementation that made every caller drive both halves by hand would be teaching the
11
+ wrong lesson.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from .client import ROOT, GateClient, Status
19
+ from .credentials import Credentials, default_directory, load, save
20
+ from .errors import (
21
+ DatabaseLocked,
22
+ GateBusy,
23
+ GateError,
24
+ MalformedToken,
25
+ NotAdmitted,
26
+ PairingError,
27
+ PairingRefused,
28
+ PairingUnanswered,
29
+ ProofRefused,
30
+ ProtocolError,
31
+ RouteRefused,
32
+ TransportError,
33
+ )
34
+ from .model import Attachment, Entry, Field, Group
35
+ from .pairing import Exchange, Pairing
36
+ from .transport import DEFAULT_TIMEOUT, LOOPBACK, PAIRING_TIMEOUT
37
+
38
+ __all__ = [
39
+ "DEFAULT_TIMEOUT",
40
+ "LOOPBACK",
41
+ "PAIRING_TIMEOUT",
42
+ "ROOT",
43
+ "Attachment",
44
+ "Credentials",
45
+ "DatabaseLocked",
46
+ "Entry",
47
+ "Exchange",
48
+ "Field",
49
+ "GateBusy",
50
+ "GateClient",
51
+ "GateError",
52
+ "Group",
53
+ "MalformedToken",
54
+ "NotAdmitted",
55
+ "Pairing",
56
+ "PairingError",
57
+ "PairingRefused",
58
+ "PairingUnanswered",
59
+ "ProofRefused",
60
+ "ProtocolError",
61
+ "RouteRefused",
62
+ "Status",
63
+ "TransportError",
64
+ "default_directory",
65
+ "load",
66
+ "pair",
67
+ "save",
68
+ ]
69
+
70
+
71
+ def pair(
72
+ port: int,
73
+ invitation: str,
74
+ name: str,
75
+ profiles: list[str] | None = None,
76
+ directory: Path | None = None,
77
+ profile: str = "default",
78
+ # Not `DEFAULT_TIMEOUT`. This is the one exchange that waits on a person, and the gate
79
+ # gives itself five minutes for it because its own worst case is 185 seconds. Hanging up
80
+ # first cancels nothing: the invitation is spent and the record is written and saved
81
+ # anyway, and only the answer is lost.
82
+ timeout: float = PAIRING_TIMEOUT,
83
+ host: str = LOOPBACK,
84
+ ) -> Credentials:
85
+ """Pair with a KeePass and keep what it hands over.
86
+
87
+ Blocks while the operator reads the dialog, which is the slow half and is meant to be. What
88
+ comes back says which profiles were actually approved, and that is not always what was asked
89
+ for: the dialog can take any of them away.
90
+ """
91
+ exchange = Pairing(port, invitation, timeout=timeout, host=host)
92
+ started = exchange.hello()
93
+ bundle = exchange.request(started, name, profiles or [])
94
+
95
+ where = directory or default_directory(profile)
96
+ return save(where, port, bundle)