datavalv-client 0.5.0__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.
- datavalv_client/__init__.py +45 -0
- datavalv_client/_format_constants.py +29 -0
- datavalv_client/cli.py +537 -0
- datavalv_client/client.py +533 -0
- datavalv_client/conformance.py +102 -0
- datavalv_client/crypto.py +183 -0
- datavalv_client/errors.py +296 -0
- datavalv_client/format.py +41 -0
- datavalv_client/pins.py +175 -0
- datavalv_client/tokens.py +89 -0
- datavalv_client-0.5.0.dist-info/METADATA +442 -0
- datavalv_client-0.5.0.dist-info/RECORD +15 -0
- datavalv_client-0.5.0.dist-info/WHEEL +4 -0
- datavalv_client-0.5.0.dist-info/entry_points.txt +2 -0
- datavalv_client-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Datavalv's Python client: the file format, the crypto, and the four calls.
|
|
2
|
+
|
|
3
|
+
`crypto` and `format` are the primitives, checked against the TypeScript
|
|
4
|
+
implementation by the conformance matrix. `client.DatavalvClient` is the HTTP
|
|
5
|
+
layer over them, and `cli` is the `datavalv` command an operator runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import CreatedVault, DatavalvClient, PushedBackup
|
|
9
|
+
from .errors import (
|
|
10
|
+
ApiError,
|
|
11
|
+
AuthError,
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
DatavalvError,
|
|
14
|
+
NetworkError,
|
|
15
|
+
QuotaExceeded,
|
|
16
|
+
RequestInFlight,
|
|
17
|
+
StorageFull,
|
|
18
|
+
UploadError,
|
|
19
|
+
VaultKeyChanged,
|
|
20
|
+
VaultNotInitialized,
|
|
21
|
+
)
|
|
22
|
+
from .pins import FilePins, KeyPins, MemoryPins
|
|
23
|
+
|
|
24
|
+
__version__ = "0.5.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ApiError",
|
|
28
|
+
"AuthError",
|
|
29
|
+
"ConfigurationError",
|
|
30
|
+
"CreatedVault",
|
|
31
|
+
"DatavalvClient",
|
|
32
|
+
"DatavalvError",
|
|
33
|
+
"FilePins",
|
|
34
|
+
"KeyPins",
|
|
35
|
+
"MemoryPins",
|
|
36
|
+
"NetworkError",
|
|
37
|
+
"PushedBackup",
|
|
38
|
+
"QuotaExceeded",
|
|
39
|
+
"RequestInFlight",
|
|
40
|
+
"StorageFull",
|
|
41
|
+
"UploadError",
|
|
42
|
+
"VaultKeyChanged",
|
|
43
|
+
"VaultNotInitialized",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Generated by scripts/gen_constants.py from format/v1.json. DO NOT EDIT."""
|
|
2
|
+
|
|
3
|
+
MAGIC = b'DVLV'
|
|
4
|
+
VERSION = 1
|
|
5
|
+
ALG_ID = 1
|
|
6
|
+
IV_LENGTH = 12
|
|
7
|
+
TAG_LENGTH = 16
|
|
8
|
+
HEADER_LENGTH = 21
|
|
9
|
+
RESERVED = b"\x00\x00"
|
|
10
|
+
|
|
11
|
+
AES_KEY_BITS = 256
|
|
12
|
+
RSA_MODULUS_BITS = 3072
|
|
13
|
+
|
|
14
|
+
HKDF_INFO = {
|
|
15
|
+
"invite": "datavalv/invite/v1",
|
|
16
|
+
"kek": "datavalv/kek/v1",
|
|
17
|
+
"login": "datavalv/login/v1",
|
|
18
|
+
"recovery": "datavalv/recovery/v1"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ARGON2ID = {
|
|
22
|
+
"hashLength": 32,
|
|
23
|
+
"memoryKiB": 65536,
|
|
24
|
+
"parallelism": 1,
|
|
25
|
+
"saltPrefix": "datavalv/v1:",
|
|
26
|
+
"timeCost": 3
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
LOGIN_HASH_PREFIX = 'Dv1!'
|
datavalv_client/cli.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""`datavalv` — the operator's side of connecting a server, as three commands.
|
|
2
|
+
|
|
3
|
+
What `/connect-system` asks of an operator is a keypair made with `openssl`, a
|
|
4
|
+
registration in the browser, a JWT minted per call because a token is single
|
|
5
|
+
use, and four HTTP calls in one order with an `x-amz-checksum-sha256` header on
|
|
6
|
+
the third. Two of those are genuinely irreducible: the keypair has to be made on
|
|
7
|
+
the machine that will sign with it, and the registration has to be done by a
|
|
8
|
+
signed-in person. The rest is this:
|
|
9
|
+
|
|
10
|
+
datavalv keygen # a keypair; the public half to paste
|
|
11
|
+
datavalv vault create --external-id … # one vault, for one customer
|
|
12
|
+
datavalv push dump.sql.gz --external-id …
|
|
13
|
+
|
|
14
|
+
**Registration is not here and will not be.** `POST /v1/apps` sits on the
|
|
15
|
+
Cognito authorizer, so it needs a signed-in account — and a command-line tool
|
|
16
|
+
that asked for a Datavalv password would be asking for the one password this
|
|
17
|
+
product is built never to see. `keygen` prints the public half; a person pastes
|
|
18
|
+
it into `/applications` and gets back the two ids.
|
|
19
|
+
|
|
20
|
+
Settings come from flags, falling back to the environment:
|
|
21
|
+
|
|
22
|
+
DATAVALV_API https://api.datavalv.se
|
|
23
|
+
DATAVALV_CLIENT_ID what /applications issued
|
|
24
|
+
DATAVALV_KEY_ID the kid of the registered signing key
|
|
25
|
+
DATAVALV_PRIVATE_KEY a PATH to the key file — never the key itself
|
|
26
|
+
|
|
27
|
+
That last one is a path on purpose. A private key in an environment variable is
|
|
28
|
+
in `ps`, in the shell history of whatever set it, and in every crash dump of the
|
|
29
|
+
process that read it.
|
|
30
|
+
|
|
31
|
+
Identifiers go to stdout and everything else to stderr, so
|
|
32
|
+
`BACKUP=$(datavalv push …)` holds a backup id and nothing else.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import os
|
|
39
|
+
import stat
|
|
40
|
+
import sys
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
from . import crypto
|
|
46
|
+
from .client import DatavalvClient
|
|
47
|
+
from .errors import ApiError, AuthError, ConfigurationError, DatavalvError, VaultKeyChanged
|
|
48
|
+
from .pins import FilePins
|
|
49
|
+
|
|
50
|
+
#: Exit codes, for a schedule that branches on them.
|
|
51
|
+
#:
|
|
52
|
+
#: `RETRY` is the one that earns its keep: a vault whose owner has not signed in
|
|
53
|
+
#: yet answers `409 tenant_not_initialized`, and a cron job that treats that as
|
|
54
|
+
#: a failure pages somebody about a server that is working perfectly. Anything
|
|
55
|
+
#: under `RETRY` is worth running again unchanged; anything under `REFUSED` or
|
|
56
|
+
#: `CONFIG` needs a person.
|
|
57
|
+
EXIT_OK = 0
|
|
58
|
+
EXIT_FAILED = 1
|
|
59
|
+
EXIT_USAGE = 2 # argparse's own, restated here so the table is complete
|
|
60
|
+
EXIT_CONFIG = 3
|
|
61
|
+
EXIT_AUTH = 4
|
|
62
|
+
EXIT_RETRY = 5
|
|
63
|
+
EXIT_REFUSED = 6
|
|
64
|
+
#: Neither of its neighbours. 5 would have a schedule push again every night
|
|
65
|
+
#: against a key that is wrong every night; 6 says the *service* refused, when
|
|
66
|
+
#: this is the client refusing the service. It needs a person, and it needs them
|
|
67
|
+
#: to know which thing to go and compare.
|
|
68
|
+
EXIT_KEY_CHANGED = 7
|
|
69
|
+
|
|
70
|
+
DEFAULT_KEY_FILENAME = "datavalv-signing.pem"
|
|
71
|
+
|
|
72
|
+
_EXIT_TABLE = """exit codes:
|
|
73
|
+
0 done
|
|
74
|
+
1 failed for a reason not covered below
|
|
75
|
+
2 the command line was wrong
|
|
76
|
+
3 a setting, or a file named on the command line, is missing or unusable
|
|
77
|
+
4 the signing key, client id or key id was refused
|
|
78
|
+
5 nothing is wrong; run it again later (vault not started yet, network, quota)
|
|
79
|
+
6 the service refused the request and a person must look at it
|
|
80
|
+
7 the key offered for the vault is not the one on record; nothing was sent"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------- settings
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve(value: str | None, env_name: str, flag: str, what: str) -> str:
|
|
87
|
+
resolved = value or os.environ.get(env_name) or ""
|
|
88
|
+
if not resolved.strip():
|
|
89
|
+
raise ConfigurationError(f"no {what}: pass {flag} or set {env_name}")
|
|
90
|
+
return resolved.strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_private_key(path_text: str) -> str:
|
|
94
|
+
"""The signing key's PEM, off disk, complaining about a readable file.
|
|
95
|
+
|
|
96
|
+
The mode check is a warning rather than a refusal. A key that others can
|
|
97
|
+
read is a real problem and the operator should be told at the moment they
|
|
98
|
+
are reminded it exists — but refusing to push a backup over a permission bit
|
|
99
|
+
would turn a warning into a missed night's copy, which is the worse of the
|
|
100
|
+
two.
|
|
101
|
+
|
|
102
|
+
**The key itself is refused here, and its value is never echoed.** A PEM in
|
|
103
|
+
`DATAVALV_PRIVATE_KEY` is the mistake the variable is a path to prevent, and
|
|
104
|
+
the natural failure — `Path(pem).read_text()` raising, reported with the
|
|
105
|
+
filename in it — would print the operator's private key to stderr and into
|
|
106
|
+
whatever collects their cron output.
|
|
107
|
+
"""
|
|
108
|
+
if "-----BEGIN" in path_text:
|
|
109
|
+
raise ConfigurationError(
|
|
110
|
+
"the signing key was given as key text rather than as a path to it. "
|
|
111
|
+
"DATAVALV_PRIVATE_KEY names a file: a private key in an environment "
|
|
112
|
+
"variable is visible in ps, in the shell history that set it and in "
|
|
113
|
+
"any crash dump. Write it to a file with mode 600 and pass the path. "
|
|
114
|
+
"(The value is not repeated here, for the same reason.)"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
path = Path(path_text).expanduser()
|
|
118
|
+
try:
|
|
119
|
+
pem = path.read_text()
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
raise ConfigurationError(
|
|
122
|
+
f"cannot read the signing key at {path}: {exc.strerror or exc}"
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
if "-----BEGIN" not in pem:
|
|
126
|
+
raise ConfigurationError(
|
|
127
|
+
f"{path} does not look like a PEM key. DATAVALV_PRIVATE_KEY is a path "
|
|
128
|
+
"to the key file, not the key."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
mode = path.stat().st_mode
|
|
132
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
133
|
+
print(
|
|
134
|
+
f"warning: {path} is readable by others (mode {stat.filemode(mode)}); "
|
|
135
|
+
"chmod 600 it",
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
return pem
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _client(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> DatavalvClient:
|
|
142
|
+
api = _resolve(args.api, "DATAVALV_API", "--api", "API address")
|
|
143
|
+
client_id = _resolve(args.client_id, "DATAVALV_CLIENT_ID", "--client-id", "client id")
|
|
144
|
+
key_id = _resolve(args.key_id, "DATAVALV_KEY_ID", "--key-id", "key id")
|
|
145
|
+
key_path = _resolve(
|
|
146
|
+
args.private_key, "DATAVALV_PRIVATE_KEY", "--private-key", "signing key"
|
|
147
|
+
)
|
|
148
|
+
return DatavalvClient(
|
|
149
|
+
api,
|
|
150
|
+
client_id,
|
|
151
|
+
key_id,
|
|
152
|
+
_read_private_key(key_path),
|
|
153
|
+
transport=transport,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------- commands
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _keygen(args: argparse.Namespace) -> int:
|
|
161
|
+
"""An RSA-3072 keypair: the private half to a file only its owner can read,
|
|
162
|
+
the public half to stdout.
|
|
163
|
+
|
|
164
|
+
**The private half is never printed and never sent anywhere.** It exists in
|
|
165
|
+
this process only long enough to be written to the file descriptor opened
|
|
166
|
+
below, which is created with mode `0600` before a byte is in it — writing it
|
|
167
|
+
world-readable and then fixing the mode leaves a window in which it is
|
|
168
|
+
readable, and a window is all it takes.
|
|
169
|
+
"""
|
|
170
|
+
path = Path(args.out).expanduser()
|
|
171
|
+
|
|
172
|
+
if path.exists() and not args.force:
|
|
173
|
+
print(
|
|
174
|
+
f"{path} already exists. Refusing to replace a signing key that may "
|
|
175
|
+
"still be registered — pass --force if that is what you mean, and "
|
|
176
|
+
"retire the old key in /applications afterwards.",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
return EXIT_FAILED
|
|
180
|
+
|
|
181
|
+
private_key = crypto.generate_keypair()
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
if args.force and path.exists():
|
|
185
|
+
# Unlinked rather than truncated, so the descriptor below is
|
|
186
|
+
# created fresh at 0600. Truncating an existing file keeps whatever
|
|
187
|
+
# mode that file already had, which may be the mode that made
|
|
188
|
+
# replacing the key necessary.
|
|
189
|
+
path.unlink()
|
|
190
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
print(f"cannot write {path}: {exc.strerror or exc}", file=sys.stderr)
|
|
193
|
+
return EXIT_FAILED
|
|
194
|
+
|
|
195
|
+
with os.fdopen(fd, "w") as handle:
|
|
196
|
+
handle.write(crypto.private_key_to_pem(private_key))
|
|
197
|
+
# Explicit, because the mode passed to `os.open` is masked by the umask and
|
|
198
|
+
# a umask that clears the owner's write bit would leave a key file its owner
|
|
199
|
+
# cannot rotate. This sets exactly 0600 — it can give the owner their own
|
|
200
|
+
# write bit back, and it grants nothing to group or other under any umask.
|
|
201
|
+
#
|
|
202
|
+
# It is a tidy-up, not the protection: the file was created at 0600 above,
|
|
203
|
+
# so there is no moment at which it exists readable. Deleting this line
|
|
204
|
+
# leaves the key safe; making `os.open` create it at 0644 does not, which is
|
|
205
|
+
# what `test_keygen_creates_the_key_at_0600_rather_than_fixing_it_afterwards`
|
|
206
|
+
# holds apart by neutering this call.
|
|
207
|
+
os.chmod(path, 0o600)
|
|
208
|
+
|
|
209
|
+
public_pem = crypto.public_key_to_pem(private_key.public_key())
|
|
210
|
+
print(public_pem, end="" if public_pem.endswith("\n") else "\n")
|
|
211
|
+
|
|
212
|
+
print(
|
|
213
|
+
f"\nThe private half is in {path}, mode 600, and must not leave this "
|
|
214
|
+
"machine.\nPaste the block above into /applications in the browser — "
|
|
215
|
+
"sign in first — and\nkeep the two values it answers with:\n"
|
|
216
|
+
" DATAVALV_CLIENT_ID identifies this server\n"
|
|
217
|
+
" DATAVALV_KEY_ID identifies this key",
|
|
218
|
+
file=sys.stderr,
|
|
219
|
+
)
|
|
220
|
+
return EXIT_OK
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _vault_create(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
|
|
224
|
+
with _client(args, transport) as client:
|
|
225
|
+
vault = client.create_vault(
|
|
226
|
+
args.external_id,
|
|
227
|
+
args.owner_email,
|
|
228
|
+
name=args.name,
|
|
229
|
+
locale=args.locale,
|
|
230
|
+
retention_count=args.retention,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
print(vault.tenant_id)
|
|
234
|
+
print(
|
|
235
|
+
f"Vault {vault.tenant_id} is {vault.status}, and {args.owner_email} has been "
|
|
236
|
+
"emailed.\n"
|
|
237
|
+
"That address is the whole of the connection: the vault appears only to an "
|
|
238
|
+
"account\nwhose confirmed address is identical to it, and a mistyped one is "
|
|
239
|
+
"reported to\nnobody. Until the owner signs in and starts the vault, a push "
|
|
240
|
+
"answers\n409 tenant_not_initialized — which is correct, and exit code 5.",
|
|
241
|
+
file=sys.stderr,
|
|
242
|
+
)
|
|
243
|
+
return EXIT_OK
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _push(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
|
|
247
|
+
data, name = _read_payload(args)
|
|
248
|
+
|
|
249
|
+
with _client(args, transport) as client:
|
|
250
|
+
pushed = client.push_backup(
|
|
251
|
+
args.external_id,
|
|
252
|
+
data,
|
|
253
|
+
name=name,
|
|
254
|
+
label=args.label,
|
|
255
|
+
idempotency_key=args.idempotency_key,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
print(pushed.backup_id)
|
|
259
|
+
print(
|
|
260
|
+
f"{pushed.size} bytes encrypted and stored as {pushed.backup_id}"
|
|
261
|
+
+ (f"; {pushed.pruned} older copy(ies) pruned" if pushed.pruned else ""),
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
)
|
|
264
|
+
if pushed.key_first_seen:
|
|
265
|
+
# Once, and never again for this vault. A line repeated every night is
|
|
266
|
+
# a line nobody reads, and the night it mattered would look like all
|
|
267
|
+
# the others.
|
|
268
|
+
print(
|
|
269
|
+
f"\nThis is the first copy this machine has pushed to {args.external_id}. "
|
|
270
|
+
"It was encrypted to the key\nthe service offered, whose fingerprint is\n\n"
|
|
271
|
+
f" {pushed.key_fingerprint}\n\n"
|
|
272
|
+
"That key is now on record here and a different one will be refused. To know "
|
|
273
|
+
"it is the vault's\nand not merely the first one offered, ask the vault's owner "
|
|
274
|
+
"for the fingerprint shown under their backups\n-- it is worked out in their "
|
|
275
|
+
"browser from the key they hold -- and check that it is the same.",
|
|
276
|
+
file=sys.stderr,
|
|
277
|
+
)
|
|
278
|
+
return EXIT_OK
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _vault_key(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
|
|
282
|
+
"""Show, trust or forget the key on record for one vault.
|
|
283
|
+
|
|
284
|
+
**`--trust` is the strongest first use there is.** The fingerprint the owner
|
|
285
|
+
reads out was worked out in their browser from the private key they hold,
|
|
286
|
+
so a pin made from it never passed through the service -- and a substituted
|
|
287
|
+
key is then refused on the first push, not the second. It and `--forget`
|
|
288
|
+
are local acts: neither makes a request.
|
|
289
|
+
|
|
290
|
+
Plain, it prints the fingerprint of the key the service offers *now*, on
|
|
291
|
+
stdout, and records nothing. Looking is not trusting.
|
|
292
|
+
"""
|
|
293
|
+
api = _resolve(args.api, "DATAVALV_API", "--api", "API address").rstrip("/")
|
|
294
|
+
api = api.removesuffix("/v1")
|
|
295
|
+
client_id = _resolve(args.client_id, "DATAVALV_CLIENT_ID", "--client-id", "client id")
|
|
296
|
+
pins = FilePins.default()
|
|
297
|
+
scope = (api, client_id, args.external_id)
|
|
298
|
+
|
|
299
|
+
if args.forget:
|
|
300
|
+
pins.forget(*scope)
|
|
301
|
+
print(
|
|
302
|
+
f"No key is on record for {args.external_id} any more. The next push records "
|
|
303
|
+
"the one it is offered.",
|
|
304
|
+
file=sys.stderr,
|
|
305
|
+
)
|
|
306
|
+
return EXIT_OK
|
|
307
|
+
|
|
308
|
+
if args.trust is not None:
|
|
309
|
+
fingerprint = args.trust.strip().lower().replace(" ", "").replace(":", "")
|
|
310
|
+
if len(fingerprint) != 64 or any(c not in "0123456789abcdef" for c in fingerprint):
|
|
311
|
+
raise ConfigurationError(
|
|
312
|
+
"--trust takes a fingerprint: 64 hexadecimal characters, as shown under "
|
|
313
|
+
"the owner's backups"
|
|
314
|
+
)
|
|
315
|
+
pins.set(*scope, fingerprint)
|
|
316
|
+
print(
|
|
317
|
+
f"{fingerprint} is on record for {args.external_id} in {pins.path}. A push "
|
|
318
|
+
"offered any other key will be refused.",
|
|
319
|
+
file=sys.stderr,
|
|
320
|
+
)
|
|
321
|
+
return EXIT_OK
|
|
322
|
+
|
|
323
|
+
with _client(args, transport) as client:
|
|
324
|
+
offered = crypto.public_key_fingerprint(
|
|
325
|
+
client.tenant_public_key(args.external_id)["publicKeyPem"]
|
|
326
|
+
)
|
|
327
|
+
print(offered)
|
|
328
|
+
|
|
329
|
+
pinned = pins.get(*scope)
|
|
330
|
+
if pinned is None:
|
|
331
|
+
print(
|
|
332
|
+
"That is the key the service offers now. It is not on record here and this "
|
|
333
|
+
"command does not\nrecord it: compare it with the fingerprint under the "
|
|
334
|
+
"owner's backups, then either push, or\nrecord the owner's with --trust.",
|
|
335
|
+
file=sys.stderr,
|
|
336
|
+
)
|
|
337
|
+
return EXIT_OK
|
|
338
|
+
if pinned == offered:
|
|
339
|
+
print("It matches the key on record for this vault.", file=sys.stderr)
|
|
340
|
+
return EXIT_OK
|
|
341
|
+
print(
|
|
342
|
+
f"It is NOT the key on record for this vault, which is\n {pinned}\n"
|
|
343
|
+
"A vault's key never changes. Do not push until you know why this one has.",
|
|
344
|
+
file=sys.stderr,
|
|
345
|
+
)
|
|
346
|
+
return EXIT_KEY_CHANGED
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _read_payload(args: argparse.Namespace) -> tuple[bytes, str | None]:
|
|
350
|
+
"""The file's bytes and the name to encrypt beside them.
|
|
351
|
+
|
|
352
|
+
`-` reads stdin, which is what makes `pg_dump … | datavalv push -` work, and
|
|
353
|
+
it carries no name: there is no filename to encrypt and inventing one would
|
|
354
|
+
put a guess in the vault.
|
|
355
|
+
"""
|
|
356
|
+
if args.file == "-":
|
|
357
|
+
return sys.stdin.buffer.read(), None
|
|
358
|
+
|
|
359
|
+
path = Path(args.file)
|
|
360
|
+
try:
|
|
361
|
+
data = path.read_bytes()
|
|
362
|
+
except OSError as exc:
|
|
363
|
+
raise ConfigurationError(f"cannot read {path}: {exc.strerror or exc}") from exc
|
|
364
|
+
|
|
365
|
+
if args.no_name:
|
|
366
|
+
return data, None
|
|
367
|
+
# The basename. What directory a source server keeps its dumps in is nobody
|
|
368
|
+
# else's business, and the name itself is encrypted under the backup's own
|
|
369
|
+
# key either way.
|
|
370
|
+
return data, args.name_as or path.name
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------- argv
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
377
|
+
parser = argparse.ArgumentParser(
|
|
378
|
+
prog="datavalv",
|
|
379
|
+
description="Push end-to-end encrypted backups to Datavalv.",
|
|
380
|
+
epilog=_EXIT_TABLE,
|
|
381
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
382
|
+
)
|
|
383
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
384
|
+
|
|
385
|
+
# The connection settings, attached to the commands that make requests.
|
|
386
|
+
# `keygen` deliberately has none of them: it talks to nothing, and a
|
|
387
|
+
# keypair made on a machine that cannot reach the API is still a keypair.
|
|
388
|
+
connection = argparse.ArgumentParser(add_help=False)
|
|
389
|
+
connection.add_argument("--api", help="API address [$DATAVALV_API]")
|
|
390
|
+
connection.add_argument("--client-id", help="registered client id [$DATAVALV_CLIENT_ID]")
|
|
391
|
+
connection.add_argument("--key-id", help="registered signing key id [$DATAVALV_KEY_ID]")
|
|
392
|
+
connection.add_argument(
|
|
393
|
+
"--private-key",
|
|
394
|
+
help="path to the signing key PEM [$DATAVALV_PRIVATE_KEY]",
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
keygen = subcommands.add_parser(
|
|
398
|
+
"keygen",
|
|
399
|
+
help="make an RSA-3072 signing keypair; print the public half",
|
|
400
|
+
description=(
|
|
401
|
+
"Write an RSA-3072 private key to a file only you can read and print "
|
|
402
|
+
"the public half for pasting into /applications. The private half is "
|
|
403
|
+
"never printed and never sent anywhere."
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
keygen.add_argument(
|
|
407
|
+
"--out", default=DEFAULT_KEY_FILENAME, help=f"where to write it [{DEFAULT_KEY_FILENAME}]"
|
|
408
|
+
)
|
|
409
|
+
keygen.add_argument(
|
|
410
|
+
"--force", action="store_true", help="replace an existing key file"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
vault = subcommands.add_parser("vault", help="vaults, one per customer")
|
|
414
|
+
vault_commands = vault.add_subparsers(dest="vault_command", required=True)
|
|
415
|
+
vault_create = vault_commands.add_parser(
|
|
416
|
+
"create",
|
|
417
|
+
parents=[connection],
|
|
418
|
+
help="create a vault and email its owner",
|
|
419
|
+
)
|
|
420
|
+
vault_create.add_argument("--external-id", required=True, help="your own id for it")
|
|
421
|
+
vault_create.add_argument(
|
|
422
|
+
"--owner-email", required=True, help="the owner's address; read it back first"
|
|
423
|
+
)
|
|
424
|
+
vault_create.add_argument("--name", help="a name for the vault")
|
|
425
|
+
vault_create.add_argument(
|
|
426
|
+
"--locale",
|
|
427
|
+
choices=("sv", "en"),
|
|
428
|
+
help="what language to write the invitation in [sv]",
|
|
429
|
+
)
|
|
430
|
+
vault_create.add_argument(
|
|
431
|
+
"--retention", type=int, help="how many copies to keep [1]"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
vault_key = vault_commands.add_parser(
|
|
435
|
+
"key",
|
|
436
|
+
parents=[connection],
|
|
437
|
+
help="show, trust or forget the fingerprint of a vault's key",
|
|
438
|
+
description=(
|
|
439
|
+
"Print the fingerprint of the key the service offers for a vault, and say "
|
|
440
|
+
"whether it is the one on record here. The vault's owner sees the same "
|
|
441
|
+
"fingerprint under their backups, worked out in their browser; compare the "
|
|
442
|
+
"two before the first push, or record theirs with --trust."
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
vault_key.add_argument("--external-id", required=True, help="the vault")
|
|
446
|
+
how = vault_key.add_mutually_exclusive_group()
|
|
447
|
+
how.add_argument(
|
|
448
|
+
"--trust",
|
|
449
|
+
metavar="FINGERPRINT",
|
|
450
|
+
help="record this fingerprint, read out by the vault's owner; makes no request",
|
|
451
|
+
)
|
|
452
|
+
how.add_argument(
|
|
453
|
+
"--forget",
|
|
454
|
+
action="store_true",
|
|
455
|
+
help="drop the key on record, after a vault was deleted and created again",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
push = subcommands.add_parser(
|
|
459
|
+
"push",
|
|
460
|
+
parents=[connection],
|
|
461
|
+
help="encrypt a file, upload it and confirm it",
|
|
462
|
+
description=(
|
|
463
|
+
"Encrypt FILE against the vault's public key, upload the ciphertext "
|
|
464
|
+
"and confirm it. All four calls or none: a copy counts only once the "
|
|
465
|
+
"last one has returned, and until then the previous copy is untouched."
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
push.add_argument("file", metavar="FILE", help="the file to push, or - for stdin")
|
|
469
|
+
push.add_argument("--external-id", required=True, help="the vault to push to")
|
|
470
|
+
push.add_argument("--label", help="a plaintext note, visible to members")
|
|
471
|
+
push.add_argument(
|
|
472
|
+
"--name-as", help="send this as the (encrypted) filename instead of FILE's"
|
|
473
|
+
)
|
|
474
|
+
push.add_argument(
|
|
475
|
+
"--no-name",
|
|
476
|
+
action="store_true",
|
|
477
|
+
help="send no filename at all, not even encrypted",
|
|
478
|
+
)
|
|
479
|
+
push.add_argument(
|
|
480
|
+
"--idempotency-key",
|
|
481
|
+
help="send Idempotency-Key, so a retried run does not store the night twice",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return parser
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _dispatch(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
|
|
488
|
+
if args.command == "keygen":
|
|
489
|
+
return _keygen(args)
|
|
490
|
+
if args.command == "vault":
|
|
491
|
+
if args.vault_command == "key":
|
|
492
|
+
return _vault_key(args, transport)
|
|
493
|
+
return _vault_create(args, transport)
|
|
494
|
+
if args.command == "push":
|
|
495
|
+
return _push(args, transport)
|
|
496
|
+
raise AssertionError(f"unreachable command {args.command!r}") # pragma: no cover
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def main(argv: list[str] | None = None, *, transport: httpx.BaseTransport | None = None) -> int:
|
|
500
|
+
"""Run one command and answer with its exit code.
|
|
501
|
+
|
|
502
|
+
`transport` is httpx's own injection seam and exists for the tests, which
|
|
503
|
+
drive the real client against a mock transport rather than a live server.
|
|
504
|
+
Nothing on the command line reaches it.
|
|
505
|
+
"""
|
|
506
|
+
args = build_parser().parse_args(argv)
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
return _dispatch(args, transport)
|
|
510
|
+
except ConfigurationError as exc:
|
|
511
|
+
print(f"datavalv: {exc}", file=sys.stderr)
|
|
512
|
+
return EXIT_CONFIG
|
|
513
|
+
except AuthError as exc:
|
|
514
|
+
print(f"datavalv: {exc}", file=sys.stderr)
|
|
515
|
+
return EXIT_AUTH
|
|
516
|
+
except VaultKeyChanged as exc:
|
|
517
|
+
print(f"datavalv: {exc}", file=sys.stderr)
|
|
518
|
+
return EXIT_KEY_CHANGED
|
|
519
|
+
except DatavalvError as exc:
|
|
520
|
+
print(f"datavalv: {exc}", file=sys.stderr)
|
|
521
|
+
return EXIT_RETRY if exc.retryable else _refusal_code(exc)
|
|
522
|
+
except ValueError as exc:
|
|
523
|
+
# A field this client checked before sending: an over-long label, a
|
|
524
|
+
# name that will not fit the encrypted-filename field.
|
|
525
|
+
print(f"datavalv: {exc}", file=sys.stderr)
|
|
526
|
+
return EXIT_FAILED
|
|
527
|
+
except KeyboardInterrupt: # pragma: no cover - needs a signal to provoke
|
|
528
|
+
print("\ndatavalv: interrupted", file=sys.stderr)
|
|
529
|
+
return 130
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _refusal_code(exc: DatavalvError) -> int:
|
|
533
|
+
return EXIT_REFUSED if isinstance(exc, ApiError) else EXIT_FAILED
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
if __name__ == "__main__": # pragma: no cover
|
|
537
|
+
raise SystemExit(main())
|