pykeepassgate 0.0.1__py3-none-any.whl

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,261 @@
1
+ """How a stranger becomes a client: the two connections, and the order that makes it revocable.
2
+
3
+ The exchange spans two connections because the gate serves one request per connection. What carries
4
+ it across is the challenge: the server issues one against the binding and the nonce of the hello,
5
+ holds it in memory, and spends it when the request takes it. So a request has to name a hello that
6
+ really happened, on a connection served with the same certificate.
7
+
8
+ **The client speaks first, and checks second.** Its proof goes out before the server has done
9
+ anything, so a captured name replayed at the gate gets no further than a refusal. Only then is the
10
+ server's own proof checked, and that check is what tells a client with no anchor yet that it
11
+ reached the KeePass whose operator holds the token, rather than whatever accepted the connection.
12
+ If it does not recompute, this stops: there is nothing else to go on.
13
+
14
+ **Nothing here spends the invitation.** A wrong proof, a refused dialog, an unanswered one: the
15
+ gate decides what each costs, and this reports it rather than guessing. A refusal spends the
16
+ invitation; a timeout does not.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import json
23
+ from collections.abc import Iterable
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ from . import proofs, sni, transport
28
+ from . import token as token_text
29
+ from .errors import (
30
+ DatabaseLocked,
31
+ GateBusy,
32
+ PairingError,
33
+ PairingRefused,
34
+ PairingUnanswered,
35
+ ProofRefused,
36
+ ProtocolError,
37
+ RouteRefused,
38
+ )
39
+
40
+ HELLO_PATH = "/pair/hello"
41
+ REQUEST_PATH = "/pair/request"
42
+
43
+ #: What `AuthorityIssuer.IsUsableSubjectName` allows a client to be called, and
44
+ #: `IsUsableProfileName` a profile: 1 to 64 characters out of these, and for a subject no
45
+ #: leading or trailing space. Mirrored here rather than discovered by asking, because the gate
46
+ #: takes the challenge before it validates, so a name it will not have costs the exchange as
47
+ #: well as the round trip. It is also what makes `proofs.asked` unambiguous: neither of its
48
+ #: separators can appear inside a part.
49
+ SUBJECT_CHARACTERS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._ -")
50
+ PROFILE_CHARACTERS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-")
51
+ NAME_LENGTH = 64
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Exchange:
56
+ """What a hello leaves behind for the request that follows it.
57
+
58
+ The binding and the nonce are the hello's, not the request's: the challenge was issued against
59
+ those, and the gate compares them. Carrying the request's own would be the mistake that makes a
60
+ request fail to follow its own hello.
61
+ """
62
+
63
+ challenge: bytes
64
+ binding: bytes
65
+ nonce: bytes
66
+
67
+
68
+ class Pairing:
69
+ """One invitation, used once.
70
+
71
+ **A lost invitation is not a refusal.** An expired or already-spent one makes the
72
+ name resolve to nothing, and the gate drops that connection without answering at
73
+ all, so it reaches a caller as `TransportError` rather than as anything in the
74
+ pairing family. `PAIRING.md` asks the client library to name that possibility,
75
+ because it is otherwise indistinguishable from nothing listening.
76
+
77
+ **A refusal from `request` spends the challenge even when it spares the
78
+ invitation.** Recovery is a fresh `hello`, never a second `request`.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ port: int,
84
+ invitation: str,
85
+ timeout: float = transport.PAIRING_TIMEOUT,
86
+ host: str = transport.LOOPBACK,
87
+ ) -> None:
88
+ """Take the port the operator gave, the token from the tab, and where to dial.
89
+
90
+ Both connections of the exchange go to the same host, because they are two halves of one
91
+ conversation with one KeePass. The proofs are over the certificate the gate serves and the
92
+ token the operator handed out, so nothing about them depends on the address.
93
+ """
94
+ self._port = port
95
+ self._token = token_text.decode(invitation)
96
+ self._timeout = timeout
97
+ self._host = host
98
+
99
+ def hello(self) -> Exchange:
100
+ """Phase 3: prove the token, then check that the answer proves it too."""
101
+ name, nonce = sni.new_name(self._token)
102
+ context = transport.unpaired_context()
103
+
104
+ with transport.connect(
105
+ self._port, context, name, host=self._host, timeout=self._timeout
106
+ ) as connection:
107
+ bound = proofs.binding(connection.server_certificate)
108
+ offered = proofs.client(self._token, bound, nonce)
109
+ reply = connection.send(
110
+ "POST", HELLO_PATH, _json({"proof": _b64(offered)})
111
+ )
112
+
113
+ _refuse(reply)
114
+ body = reply.json()
115
+ challenge = _bytes(body, "challenge")
116
+ answered = _bytes(body, "proof")
117
+
118
+ expected = proofs.server(self._token, bound, nonce, challenge)
119
+ if not proofs.matches(expected, answered):
120
+ # Whatever answered is not holding this token. A client with no anchor has nothing else
121
+ # to check, so this is where it stops rather than where it warns.
122
+ raise ProofRefused("the gate's proof did not verify, so this is not the right KeePass")
123
+
124
+ return Exchange(challenge=challenge, binding=bound, nonce=nonce)
125
+
126
+ def request(self, exchange: Exchange, name: str, profiles: list[str]) -> dict[str, Any]:
127
+ """Phases 4 and 5: ask, wait for the operator, and take the bundle if there is one.
128
+
129
+ The profiles asked for are a ceiling rather than a grant. The operator can take any of them
130
+ away in the dialog, and what comes back says what was actually approved, which is not
131
+ always what was asked for.
132
+ """
133
+ _usable(name, profiles)
134
+
135
+ server_name, _ = sni.new_name(self._token)
136
+ context = transport.unpaired_context()
137
+
138
+ proof = proofs.request(
139
+ self._token,
140
+ exchange.binding,
141
+ exchange.nonce,
142
+ exchange.challenge,
143
+ proofs.asked(name, profiles),
144
+ )
145
+
146
+ body = _json({"name": name, "profiles": profiles, "proof": _b64(proof)})
147
+
148
+ with transport.connect(
149
+ self._port, context, server_name, host=self._host, timeout=self._timeout
150
+ ) as connection:
151
+ # The hello's proof was over the certificate the *hello* was served with,
152
+ # and this is a different connection: an unpaired client has no anchor, so
153
+ # without this it would take its own key, its trust anchor and the gate
154
+ # key from whatever is answering the port now. The gate makes the mirror
155
+ # comparison and answers `409 no exchange` (`PairingHandler.Request`);
156
+ # this is the half that protects the client, which is the party being
157
+ # handed something to trust.
158
+ if not proofs.matches(
159
+ proofs.binding(connection.server_certificate), exchange.binding
160
+ ):
161
+ raise ProofRefused(
162
+ "the request reached a different certificate from its own hello"
163
+ )
164
+
165
+ reply = connection.send("POST", REQUEST_PATH, body)
166
+
167
+ _refuse(reply)
168
+ return reply.json()
169
+
170
+
171
+ def _usable(name: str, profiles: Iterable[str]) -> None:
172
+ """Refuse what the gate would refuse, without spending a connection to be told.
173
+
174
+ `PairingHandler.Request` checks the name and the profiles before it takes the challenge, so
175
+ `400 name` and `400 profile` cost nothing on the gate's side: the exchange survives, and the
176
+ same hello can be followed by a corrected request. What this saves is the round trip and a
177
+ message that names the rule that was broken rather than the field it was broken in.
178
+ """
179
+ if not 0 < len(name) <= NAME_LENGTH or not SUBJECT_CHARACTERS.issuperset(name):
180
+ raise PairingError(f"that is not a name this database will issue to: {name!r}")
181
+
182
+ if name.startswith(" ") or name.endswith(" "):
183
+ # Measured on the plugin's side: `X500DistinguishedName` strips the space either end,
184
+ # so two names that differ only there become one identity in a record and in a log.
185
+ raise PairingError("a name cannot begin or end with a space")
186
+
187
+ for profile in profiles:
188
+ if not 0 < len(profile) <= NAME_LENGTH or not PROFILE_CHARACTERS.issuperset(profile):
189
+ raise PairingError(f"that is not a profile name: {profile!r}")
190
+
191
+ if profile.lower() == "default":
192
+ raise PairingError("`default` is the rule that applies when none is named")
193
+
194
+
195
+ def _refuse(reply: transport.Response) -> None:
196
+ """Turn what the gate said into what a caller can act on.
197
+
198
+ Each of these is a different thing to do next, which is why they are different exceptions: wait
199
+ and retry, ask the operator to look at the screen, or stop and get a new invitation.
200
+ """
201
+ if reply.status == 200:
202
+ return
203
+
204
+ error = reply.error()
205
+
206
+ if reply.status == 503 and error == "locked":
207
+ raise DatabaseLocked("the database is locked, so pairing has nothing to issue from")
208
+
209
+ if reply.status == 503:
210
+ # The invitation survives, because the gate takes the challenge before it reaches
211
+ # the check that answers this and burns the invitation nowhere on this path. The
212
+ # challenge does not survive, so retrying this half alone earns `409 no exchange`.
213
+ # What can be tried again is the whole pairing, which is what `api.pair` is.
214
+ raise GateBusy(
215
+ "the gate cannot write a record right now, most likely because a group "
216
+ "dialog is open. The invitation is still good, but this exchange is not: "
217
+ "say hello again."
218
+ )
219
+
220
+ if reply.status == 504:
221
+ raise PairingUnanswered("nobody answered the dialog, and the invitation is not spent")
222
+
223
+ if reply.status == 403:
224
+ raise PairingRefused("the operator refused, and the invitation is spent")
225
+
226
+ if reply.status == 401 and error == "proof":
227
+ raise ProofRefused("the gate refused the proof, so that is not the token it is holding")
228
+
229
+ if reply.status == 404:
230
+ # A path under `/pair/` that is neither hello nor request. Not what a connection
231
+ # that lost its invitation gets: that name resolves to nothing and the gate drops
232
+ # the connection without a word, so it arrives here as a `TransportError`.
233
+ raise PairingError(
234
+ "the gate has no such pairing route, so these two halves disagree about the wire"
235
+ )
236
+
237
+ if reply.status == 409:
238
+ raise PairingError(
239
+ "the invitation is gone: it expired, it was spent, or this did not follow its own hello"
240
+ )
241
+
242
+ raise RouteRefused(reply.status, error)
243
+
244
+
245
+ def _bytes(body: dict[str, Any], key: str) -> bytes:
246
+ value = body.get(key)
247
+ if not isinstance(value, str):
248
+ raise ProtocolError(f"the gate's answer is missing {key}")
249
+
250
+ try:
251
+ return base64.b64decode(value, validate=True)
252
+ except ValueError as error:
253
+ raise ProtocolError(f"the gate's {key} is not base64") from error
254
+
255
+
256
+ def _b64(value: bytes) -> str:
257
+ return base64.b64encode(value).decode("ascii")
258
+
259
+
260
+ def _json(body: dict[str, Any]) -> str:
261
+ return json.dumps(body, separators=(",", ":"))
@@ -0,0 +1,89 @@
1
+ """The proofs the two sides exchange before anybody is asked to approve anything.
2
+
3
+ Three values, all ``HMAC-SHA256`` under the invitation token, each over a different label so that no
4
+ proof can ever be replayed as another.
5
+
6
+ **The binding is RFC 5929 ``tls-server-end-point``**: ``SHA-256`` over the DER of the certificate
7
+ the server is actually serving with. It needs no TLS interface on either side, which is what makes
8
+ it usable here: the server knows the certificate it chose, and the client has just been sent it.
9
+ ``SHA-256`` is not negotiated, because this authority signs with ``ecdsa-with-SHA256`` and nothing
10
+ else. https://www.rfc-editor.org/rfc/rfc5929#section-4
11
+
12
+ What each one is for:
13
+
14
+ - **The client speaks first.** An impostor learns nothing and does no work until the caller has
15
+ shown it holds the token, so a captured name replayed at the gate dies at the first route rather
16
+ than in front of the operator.
17
+ - **The server answers.** An unpaired client has no anchor yet and would otherwise accept whatever
18
+ certificate arrived. Because the binding hashes the certificate in use, a machine in the middle
19
+ can only produce a proof over its own certificate, which recomputes to something else here.
20
+ - **The request is proved too.** ``tls-server-end-point`` is a constant for the life of a
21
+ certificate, so the freshness comes from the nonce the client chose and the challenge the server
22
+ chose, and the request carries the name and profiles being asked for, so what the operator is
23
+ shown is what was signed for.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hmac
29
+ from collections.abc import Iterable, Sequence
30
+ from hashlib import sha256
31
+
32
+ CLIENT_LABEL = b"kpg-client-proof"
33
+ SERVER_LABEL = b"kpg-server-proof"
34
+ REQUEST_LABEL = b"kpg-request-proof"
35
+
36
+ #: ``HMAC-SHA256`` output, so every proof is this long.
37
+ LENGTH = 32
38
+
39
+
40
+ def binding(server_certificate_der: bytes) -> bytes:
41
+ """RFC 5929 ``tls-server-end-point`` over the certificate the connection is served with."""
42
+ return sha256(server_certificate_der).digest()
43
+
44
+
45
+ def client(token: bytes, bound: bytes, nonce: bytes) -> bytes:
46
+ """What the client sends first, over the certificate it was given and the name it chose."""
47
+ return _compute(token, CLIENT_LABEL, [bound, nonce])
48
+
49
+
50
+ def server(token: bytes, bound: bytes, nonce: bytes, challenge: bytes) -> bytes:
51
+ """What the server answers with, and what tells a client with no anchor who it reached."""
52
+ return _compute(token, SERVER_LABEL, [bound, nonce, challenge])
53
+
54
+
55
+ def request(
56
+ token: bytes, bound: bytes, nonce: bytes, challenge: bytes, asked_for: bytes
57
+ ) -> bytes:
58
+ """What commits the request to the name and the profiles it asks for."""
59
+ return _compute(token, REQUEST_LABEL, [bound, nonce, challenge, asked_for])
60
+
61
+
62
+ def asked(name: str, profiles: Iterable[str]) -> bytes:
63
+ """What a pairing request commits to: the name asked for, and the profiles, in order.
64
+
65
+ Both sides build this from the same values, so a request altered on the way no longer proves
66
+ itself, and the operator is shown what was signed for rather than what arrived.
67
+ """
68
+ return f"{name}\n{','.join(profiles)}".encode()
69
+
70
+
71
+ def matches(expected: bytes, offered: bytes) -> bool:
72
+ """Compare two proofs in a time that does not depend on where they first differ."""
73
+ return hmac.compare_digest(expected, offered)
74
+
75
+
76
+ def _compute(token: bytes, label: bytes, parts: Sequence[bytes]) -> bytes:
77
+ """Every part length prefixed, so no two different sets of parts produce the same bytes.
78
+
79
+ Without the prefix a name and a profile list could be shuffled across the boundary between them
80
+ and still verify.
81
+ """
82
+ buffer = bytearray(label)
83
+ buffer.append(0)
84
+
85
+ for part in parts:
86
+ buffer += len(part).to_bytes(4, "big")
87
+ buffer += part
88
+
89
+ return hmac.new(token, bytes(buffer), sha256).digest()
pykeepassgate/py.typed ADDED
File without changes
pykeepassgate/sni.py ADDED
@@ -0,0 +1,63 @@
1
+ """The name a client puts in its ClientHello.
2
+
3
+ It is how a connection says which database it is for without naming it.
4
+
5
+ ``hex(nonce) "." hex(HMAC-SHA256(key, nonce)[0..16]) ".kpg"``, rebuilt for every connection. Two
6
+ labels because 64 hex characters exceed the 63-octet limit on one, which is RFC 1035 section 2.3.4.
7
+ https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4
8
+
9
+ The key is the gate key once a client is paired, and the invitation token while it is pairing. They
10
+ are the same length for exactly that reason, so one construction covers both.
11
+
12
+ Two honest limits, carried over from the plugin's own account of it. The tag is replayable, because
13
+ the nonce is the client's and the server keeps no record of which have been used, so this stops
14
+ blind probing rather than somebody who captured a connection. And the name travels in the clear, so
15
+ an observer learns that some database is being addressed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hmac
21
+ import secrets
22
+ from hashlib import sha256
23
+
24
+ #: The last label. Not a real top level domain and never resolved: nothing looks this name up,
25
+ #: because the client is told where to connect and the name only says which database it means once
26
+ #: it is there.
27
+ SUFFIX = "kpg"
28
+
29
+ #: Sixteen bytes, so the label is 32 hex characters and fits what a DNS label allows.
30
+ NONCE_LENGTH = 16
31
+
32
+ #: Thirty-two, which is what `CA.md` 204 specifies and what `AuthorityMaterial.SniKeyLength`
33
+ #: refuses anything else against. The invitation token is the same length, which is what lets one
34
+ #: construction cover both.
35
+ KEY_LENGTH = 32
36
+
37
+ #: The first sixteen bytes of the HMAC, for the same reason. Truncating an HMAC is what RFC 2104
38
+ #: section 5 describes, and 128 bits is far more than a value an attacker gets one guess at needs.
39
+ #: https://www.rfc-editor.org/rfc/rfc2104#section-5
40
+ TAG_LENGTH = 16
41
+
42
+
43
+ def tag(key: bytes, nonce: bytes) -> bytes:
44
+ """The tag for a nonce under a key, truncated as the name requires."""
45
+ return hmac.new(key, nonce, sha256).digest()[:TAG_LENGTH]
46
+
47
+
48
+ def name_for(key: bytes, nonce: bytes) -> str:
49
+ """The name for a nonce that has already been chosen."""
50
+ if len(nonce) != NONCE_LENGTH:
51
+ raise ValueError(f"a nonce is {NONCE_LENGTH} bytes, not {len(nonce)}")
52
+
53
+ return f"{nonce.hex()}.{tag(key, nonce).hex()}.{SUFFIX}"
54
+
55
+
56
+ def new_name(key: bytes) -> tuple[str, bytes]:
57
+ """A fresh name for one connection, and the nonce it was built from.
58
+
59
+ The nonce is returned because the proofs are computed over it: the client picks it, so it is
60
+ the client's half of the freshness in an exchange where the binding is a constant.
61
+ """
62
+ nonce = secrets.token_bytes(NONCE_LENGTH)
63
+ return name_for(key, nonce), nonce
pykeepassgate/token.py ADDED
@@ -0,0 +1,62 @@
1
+ """The secret an operator hands to an invitee, and how it is written down.
2
+
3
+ Thirty-two bytes, because the same value is used as a gate key and a gate key is thirty-two bytes.
4
+ It is written as base64url without padding, so it survives being pasted into a URL, a shell, a
5
+ configuration file or a chat window without anything quoting it. That alphabet is RFC 4648
6
+ section 5, and the padding it omits is section 3.2. https://www.rfc-editor.org/rfc/rfc4648#section-5
7
+
8
+ The plugin only ever encodes and this only ever decodes, so `encode` exists here to be tested
9
+ against `decode` rather than because a client needs it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+
16
+ from .errors import MalformedToken
17
+
18
+ #: The same length as a gate key, because a token is used as one.
19
+ LENGTH = 32
20
+
21
+ #: Thirty-two bytes in base64url is forty-three characters, with no padding.
22
+ TEXT_LENGTH = 43
23
+
24
+ _ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
25
+
26
+
27
+ def encode(token: bytes) -> str:
28
+ """Write a token the way the tab writes it."""
29
+ if len(token) != LENGTH:
30
+ raise MalformedToken(f"a token is {LENGTH} bytes, not {len(token)}")
31
+
32
+ return base64.urlsafe_b64encode(token).decode("ascii").rstrip("=")
33
+
34
+
35
+ def decode(text: str) -> bytes:
36
+ """Read one back.
37
+
38
+ Refuses anything that is not exactly a token: the wrong length, a character outside the
39
+ alphabet, or padding, which the plugin never writes. Fail closed, in the same way and for the
40
+ same reason the plugin's own decoder does.
41
+ """
42
+ if len(text) != TEXT_LENGTH:
43
+ raise MalformedToken(
44
+ f"a token is {TEXT_LENGTH} characters, not {len(text)}"
45
+ )
46
+
47
+ if not _ALPHABET.issuperset(text):
48
+ raise MalformedToken("a token is base64url, and this is not")
49
+
50
+ # The padding the encoder omits, put back so the standard library will read it. 43 characters
51
+ # is 32 bytes and one short of a whole block, so one "=" is what is missing; a second is
52
+ # harmless and saves counting.
53
+ token = base64.urlsafe_b64decode(text + "==")
54
+
55
+ # 43 characters carry 258 bits for a 256 bit token, so the last one has two bits the token
56
+ # does not use and the decoder does not look at: four spellings decode to the same bytes.
57
+ # Re-encoding refuses the three the plugin would never write. There is no length check after
58
+ # this, because 43 characters of the alphabet are 32 bytes and can be nothing else.
59
+ if encode(token) != text:
60
+ raise MalformedToken("a token has one spelling, and this is not it")
61
+
62
+ return token