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.
- pykeepassgate-0.0.1/.gitignore +34 -0
- pykeepassgate-0.0.1/LICENSE +21 -0
- pykeepassgate-0.0.1/PKG-INFO +105 -0
- pykeepassgate-0.0.1/pyproject.toml +141 -0
- pykeepassgate-0.0.1/src/pykeepassgate/README.md +92 -0
- pykeepassgate-0.0.1/src/pykeepassgate/__init__.py +57 -0
- pykeepassgate-0.0.1/src/pykeepassgate/api.py +96 -0
- pykeepassgate-0.0.1/src/pykeepassgate/cli.py +377 -0
- pykeepassgate-0.0.1/src/pykeepassgate/client.py +416 -0
- pykeepassgate-0.0.1/src/pykeepassgate/credentials.py +323 -0
- pykeepassgate-0.0.1/src/pykeepassgate/errors.py +98 -0
- pykeepassgate-0.0.1/src/pykeepassgate/model.py +210 -0
- pykeepassgate-0.0.1/src/pykeepassgate/pairing.py +261 -0
- pykeepassgate-0.0.1/src/pykeepassgate/proofs.py +89 -0
- pykeepassgate-0.0.1/src/pykeepassgate/py.typed +0 -0
- pykeepassgate-0.0.1/src/pykeepassgate/sni.py +63 -0
- pykeepassgate-0.0.1/src/pykeepassgate/token.py +62 -0
- pykeepassgate-0.0.1/src/pykeepassgate/transport.py +374 -0
|
@@ -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)
|