hide-protocol 0.5.0__py3-none-win_amd64.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.
Potentially problematic release.
This version of hide-protocol might be problematic. Click here for more details.
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""HIDE — encrypt to a person, not to a key.
|
|
2
|
+
|
|
3
|
+
EXPERIMENTAL AND UNAUDITED. Do not protect data you cannot afford to lose or
|
|
4
|
+
expose. A successful decryption proves the data was not altered; it does *not*
|
|
5
|
+
prove who created it.
|
|
6
|
+
|
|
7
|
+
import hide_protocol as hide
|
|
8
|
+
|
|
9
|
+
secret = hide.SecretKey.generate()
|
|
10
|
+
box = hide.encrypt(b"hello", [secret.public_key()])
|
|
11
|
+
assert hide.decrypt(box, secret).data == b"hello"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ctypes
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Sequence
|
|
19
|
+
|
|
20
|
+
from . import _binding as _b
|
|
21
|
+
from ._binding import MIN_PASSPHRASE_LEN, PUBLIC_KEY_LEN
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"HideError",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"WrongPassphrase",
|
|
27
|
+
"NoMatchingRecipient",
|
|
28
|
+
"NotAKeyFile",
|
|
29
|
+
"SecretKey",
|
|
30
|
+
"SigningIdentity",
|
|
31
|
+
"SpentNonces",
|
|
32
|
+
"ChallengeExpired",
|
|
33
|
+
"ChallengeReplayed",
|
|
34
|
+
"sign",
|
|
35
|
+
"verify",
|
|
36
|
+
"new_challenge",
|
|
37
|
+
"Decrypted",
|
|
38
|
+
"encrypt",
|
|
39
|
+
"decrypt",
|
|
40
|
+
"armor_public_key",
|
|
41
|
+
"dearmor_public_key",
|
|
42
|
+
"inspect_key",
|
|
43
|
+
"MIN_PASSPHRASE_LEN",
|
|
44
|
+
"PUBLIC_KEY_LEN",
|
|
45
|
+
"SIGNATURE_LEN",
|
|
46
|
+
"VERIFYING_KEY_LEN",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
__version__ = _b.lib.hide_version().decode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HideError(Exception):
|
|
54
|
+
"""Base class for every failure this library reports."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AuthenticationError(HideError):
|
|
58
|
+
"""The data was altered, or is not a HIDE container."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WrongPassphrase(HideError):
|
|
62
|
+
"""The passphrase is wrong, or the key file was modified."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NoMatchingRecipient(HideError):
|
|
66
|
+
"""This key was not one of the recipients."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NotAKeyFile(HideError):
|
|
70
|
+
"""The bytes are not a HIDE key."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ChallengeExpired(HideError):
|
|
74
|
+
"""The challenge expired before it was answered."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ChallengeReplayed(HideError):
|
|
78
|
+
"""This challenge was already answered. Almost certainly a replay."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_ERRORS = {
|
|
82
|
+
_b.ERR_INVALID_ARGUMENT: ValueError,
|
|
83
|
+
_b.ERR_WRONG_PASSPHRASE: WrongPassphrase,
|
|
84
|
+
_b.ERR_NOT_A_KEY: NotAKeyFile,
|
|
85
|
+
_b.ERR_AUTHENTICATION: AuthenticationError,
|
|
86
|
+
_b.ERR_NO_MATCHING_RECIPIENT: NoMatchingRecipient,
|
|
87
|
+
_b.ERR_MALFORMED: AuthenticationError,
|
|
88
|
+
_b.ERR_TOO_LARGE: ValueError,
|
|
89
|
+
_b.ERR_CHALLENGE_EXPIRED: ChallengeExpired,
|
|
90
|
+
_b.ERR_CHALLENGE_REPLAYED: ChallengeReplayed,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check(code: int) -> None:
|
|
95
|
+
if code == _b.OK:
|
|
96
|
+
return
|
|
97
|
+
message = _b.lib.hide_error_message(code).decode()
|
|
98
|
+
raise _ERRORS.get(code, HideError)(message)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _take(buffer: _b.Buffer) -> bytes:
|
|
102
|
+
"""Copies a native buffer into Python bytes and frees the original."""
|
|
103
|
+
try:
|
|
104
|
+
if not buffer.data:
|
|
105
|
+
return b""
|
|
106
|
+
return bytes(ctypes.cast(
|
|
107
|
+
buffer.data, ctypes.POINTER(ctypes.c_uint8 * buffer.len)
|
|
108
|
+
).contents)
|
|
109
|
+
finally:
|
|
110
|
+
_b.lib.hide_buffer_free(ctypes.byref(buffer))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _take_text(buffer: _b.Buffer) -> str | None:
|
|
114
|
+
"""Metadata arrives as length-prefixed UTF-8; empty means absent."""
|
|
115
|
+
raw = _take(buffer)
|
|
116
|
+
return raw.decode("utf-8", "replace") if raw else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SecretKey:
|
|
120
|
+
"""A secret key. The bytes stay in the native library and are never exposed.
|
|
121
|
+
|
|
122
|
+
Release it with ``close()`` or a ``with`` block; it is also released when
|
|
123
|
+
garbage collected.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
__slots__ = ("_handle",)
|
|
127
|
+
|
|
128
|
+
def __init__(self, handle: ctypes.c_void_p) -> None:
|
|
129
|
+
self._handle = handle
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def generate(cls) -> "SecretKey":
|
|
133
|
+
handle = ctypes.c_void_p()
|
|
134
|
+
public = _b.lib.hide_buffer_empty()
|
|
135
|
+
_check(_b.lib.hide_keypair_generate(ctypes.byref(handle), ctypes.byref(public)))
|
|
136
|
+
_b.lib.hide_buffer_free(ctypes.byref(public))
|
|
137
|
+
return cls(handle)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def load(cls, data: bytes, passphrase: str | None = None) -> "SecretKey":
|
|
141
|
+
"""Loads a key file. A protected key without its passphrase fails."""
|
|
142
|
+
handle = ctypes.c_void_p()
|
|
143
|
+
_check(
|
|
144
|
+
_b.lib.hide_secret_key_open(
|
|
145
|
+
data,
|
|
146
|
+
len(data),
|
|
147
|
+
passphrase.encode() if passphrase is not None else None,
|
|
148
|
+
ctypes.byref(handle),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return cls(handle)
|
|
152
|
+
|
|
153
|
+
def public_key(self) -> bytes:
|
|
154
|
+
self._alive()
|
|
155
|
+
out = _b.lib.hide_buffer_empty()
|
|
156
|
+
_check(_b.lib.hide_secret_key_public(self._handle, ctypes.byref(out)))
|
|
157
|
+
return _take(out)
|
|
158
|
+
|
|
159
|
+
def protect(self, passphrase: str) -> bytes:
|
|
160
|
+
"""Seals this key with a passphrase, for writing to disk.
|
|
161
|
+
|
|
162
|
+
A forgotten passphrase cannot be recovered: there is no escrow.
|
|
163
|
+
"""
|
|
164
|
+
self._alive()
|
|
165
|
+
if len(passphrase) < MIN_PASSPHRASE_LEN:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"the passphrase must be at least {MIN_PASSPHRASE_LEN} characters"
|
|
168
|
+
)
|
|
169
|
+
out = _b.lib.hide_buffer_empty()
|
|
170
|
+
_check(
|
|
171
|
+
_b.lib.hide_secret_key_protect(
|
|
172
|
+
self._handle, passphrase.encode(), ctypes.byref(out)
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return _take(out)
|
|
176
|
+
|
|
177
|
+
def close(self) -> None:
|
|
178
|
+
if getattr(self, "_handle", None):
|
|
179
|
+
_b.lib.hide_secret_key_free(self._handle)
|
|
180
|
+
self._handle = None
|
|
181
|
+
|
|
182
|
+
def _alive(self) -> None:
|
|
183
|
+
if not getattr(self, "_handle", None):
|
|
184
|
+
raise ValueError("this key has been closed")
|
|
185
|
+
|
|
186
|
+
def __enter__(self) -> "SecretKey":
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *_: object) -> None:
|
|
190
|
+
self.close()
|
|
191
|
+
|
|
192
|
+
def __del__(self) -> None:
|
|
193
|
+
self.close()
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str:
|
|
196
|
+
# Never render key material, not even a fingerprint of it.
|
|
197
|
+
state = "closed" if not getattr(self, "_handle", None) else "open"
|
|
198
|
+
return f"<hide_protocol.SecretKey {state}>"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class Decrypted:
|
|
203
|
+
"""A verified payload. Reaching this object means it authenticated."""
|
|
204
|
+
|
|
205
|
+
data: bytes
|
|
206
|
+
filename: str | None = None
|
|
207
|
+
media_type: str | None = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def encrypt(
|
|
211
|
+
plaintext: bytes,
|
|
212
|
+
recipients: Sequence[bytes],
|
|
213
|
+
*,
|
|
214
|
+
filename: str | None = None,
|
|
215
|
+
media_type: str | None = None,
|
|
216
|
+
) -> bytes:
|
|
217
|
+
"""Encrypts for 1..64 recipient public keys."""
|
|
218
|
+
if not 1 <= len(recipients) <= 64:
|
|
219
|
+
raise ValueError("there must be between 1 and 64 recipients")
|
|
220
|
+
joined = bytearray()
|
|
221
|
+
for key in recipients:
|
|
222
|
+
if len(key) != PUBLIC_KEY_LEN:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"a public key is {PUBLIC_KEY_LEN} bytes, got {len(key)}"
|
|
225
|
+
)
|
|
226
|
+
joined += key
|
|
227
|
+
|
|
228
|
+
out = _b.lib.hide_buffer_empty()
|
|
229
|
+
_check(
|
|
230
|
+
_b.lib.hide_encrypt(
|
|
231
|
+
plaintext,
|
|
232
|
+
len(plaintext),
|
|
233
|
+
bytes(joined),
|
|
234
|
+
len(recipients),
|
|
235
|
+
filename.encode() if filename else None,
|
|
236
|
+
media_type.encode() if media_type else None,
|
|
237
|
+
ctypes.byref(out),
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
return _take(out)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def decrypt(container: bytes, secret: SecretKey) -> Decrypted:
|
|
244
|
+
"""Decrypts and verifies.
|
|
245
|
+
|
|
246
|
+
Nothing is returned unless the whole payload authenticates. The filename is
|
|
247
|
+
attacker-controlled: never use it to choose an output path.
|
|
248
|
+
"""
|
|
249
|
+
secret._alive()
|
|
250
|
+
out = _b.lib.hide_buffer_empty()
|
|
251
|
+
filename = _b.lib.hide_buffer_empty()
|
|
252
|
+
media_type = _b.lib.hide_buffer_empty()
|
|
253
|
+
_check(
|
|
254
|
+
_b.lib.hide_decrypt(
|
|
255
|
+
container,
|
|
256
|
+
len(container),
|
|
257
|
+
secret._handle,
|
|
258
|
+
ctypes.byref(out),
|
|
259
|
+
ctypes.byref(filename),
|
|
260
|
+
ctypes.byref(media_type),
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return Decrypted(
|
|
264
|
+
data=_take(out),
|
|
265
|
+
filename=_take_text(filename),
|
|
266
|
+
media_type=_take_text(media_type),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def armor_public_key(public_key: bytes) -> str:
|
|
271
|
+
"""Renders a public key as pasteable text."""
|
|
272
|
+
out = _b.lib.hide_buffer_empty()
|
|
273
|
+
_check(
|
|
274
|
+
_b.lib.hide_public_key_armor(public_key, len(public_key), ctypes.byref(out))
|
|
275
|
+
)
|
|
276
|
+
return _take_text(out) or ""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def dearmor_public_key(text: str) -> bytes:
|
|
280
|
+
out = _b.lib.hide_buffer_empty()
|
|
281
|
+
_check(_b.lib.hide_public_key_dearmor(text.encode(), ctypes.byref(out)))
|
|
282
|
+
return _take(out)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def inspect_key(data: bytes) -> str:
|
|
286
|
+
"""Returns ``"raw"`` or ``"protected"`` without needing the passphrase."""
|
|
287
|
+
kind = ctypes.c_int32(-1)
|
|
288
|
+
_check(_b.lib.hide_inspect_key(data, len(data), ctypes.byref(kind)))
|
|
289
|
+
return "protected" if kind.value == _b.KEY_PROTECTED else "raw"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
SIGNATURE_LEN = _b.SIGNATURE_LEN
|
|
293
|
+
VERIFYING_KEY_LEN = _b.VERIFYING_KEY_LEN
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class SigningIdentity:
|
|
297
|
+
"""A signing key. The seed stays in the native library and is never exposed.
|
|
298
|
+
|
|
299
|
+
A key file written before signatures existed carries no signing seed and
|
|
300
|
+
raises :class:`NotAKeyFile` rather than being silently downgraded.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
__slots__ = ("_handle",)
|
|
304
|
+
|
|
305
|
+
def __init__(self, handle: ctypes.c_void_p) -> None:
|
|
306
|
+
self._handle = handle
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def generate(passphrase: str) -> bytes:
|
|
310
|
+
"""Creates an identity, returning the sealed key file to store.
|
|
311
|
+
|
|
312
|
+
One seed backs both encryption and signing, so there is a single thing
|
|
313
|
+
to back up. A forgotten passphrase cannot be recovered.
|
|
314
|
+
"""
|
|
315
|
+
if len(passphrase) < MIN_PASSPHRASE_LEN:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"the passphrase must be at least {MIN_PASSPHRASE_LEN} characters"
|
|
318
|
+
)
|
|
319
|
+
out = _b.lib.hide_buffer_empty()
|
|
320
|
+
_check(_b.lib.hide_identity_generate(passphrase.encode(), ctypes.byref(out)))
|
|
321
|
+
return _take(out)
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def load(cls, data: bytes, passphrase: str | None = None) -> "SigningIdentity":
|
|
325
|
+
handle = ctypes.c_void_p()
|
|
326
|
+
_check(
|
|
327
|
+
_b.lib.hide_signing_identity_open(
|
|
328
|
+
data,
|
|
329
|
+
len(data),
|
|
330
|
+
passphrase.encode() if passphrase is not None else None,
|
|
331
|
+
ctypes.byref(handle),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
return cls(handle)
|
|
335
|
+
|
|
336
|
+
def public_key(self) -> bytes:
|
|
337
|
+
"""The shareable verifying key, for others to check signatures with."""
|
|
338
|
+
self._alive()
|
|
339
|
+
out = _b.lib.hide_buffer_empty()
|
|
340
|
+
_check(_b.lib.hide_signing_identity_public(self._handle, ctypes.byref(out)))
|
|
341
|
+
return _take(out)
|
|
342
|
+
|
|
343
|
+
def sign(self, context: bytes, message: bytes) -> bytes:
|
|
344
|
+
"""Signs ``message`` under ``context``.
|
|
345
|
+
|
|
346
|
+
``context`` separates uses of one identity, so a signature made for one
|
|
347
|
+
purpose cannot be replayed as another. Never let a remote party choose
|
|
348
|
+
it.
|
|
349
|
+
"""
|
|
350
|
+
self._alive()
|
|
351
|
+
out = _b.lib.hide_buffer_empty()
|
|
352
|
+
_check(
|
|
353
|
+
_b.lib.hide_sign_message(
|
|
354
|
+
self._handle, context, len(context), message, len(message),
|
|
355
|
+
ctypes.byref(out),
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
return _take(out)
|
|
359
|
+
|
|
360
|
+
def answer(self, challenge: bytes) -> bytes:
|
|
361
|
+
"""Answers a challenge, proving possession to whoever issued it."""
|
|
362
|
+
self._alive()
|
|
363
|
+
out = _b.lib.hide_buffer_empty()
|
|
364
|
+
_check(
|
|
365
|
+
_b.lib.hide_challenge_answer(
|
|
366
|
+
self._handle, challenge, len(challenge), ctypes.byref(out)
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
return _take(out)
|
|
370
|
+
|
|
371
|
+
def close(self) -> None:
|
|
372
|
+
if getattr(self, "_handle", None):
|
|
373
|
+
_b.lib.hide_signing_identity_free(self._handle)
|
|
374
|
+
self._handle = None
|
|
375
|
+
|
|
376
|
+
def _alive(self) -> None:
|
|
377
|
+
if not getattr(self, "_handle", None):
|
|
378
|
+
raise ValueError("this identity has been closed")
|
|
379
|
+
|
|
380
|
+
def __enter__(self) -> "SigningIdentity":
|
|
381
|
+
return self
|
|
382
|
+
|
|
383
|
+
def __exit__(self, *_: object) -> None:
|
|
384
|
+
self.close()
|
|
385
|
+
|
|
386
|
+
def __del__(self) -> None:
|
|
387
|
+
self.close()
|
|
388
|
+
|
|
389
|
+
def __repr__(self) -> str:
|
|
390
|
+
state = "closed" if not getattr(self, "_handle", None) else "open"
|
|
391
|
+
return f"<hide_protocol.SigningIdentity {state}>"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def sign(identity: SigningIdentity, context: bytes, message: bytes) -> bytes:
|
|
395
|
+
return identity.sign(context, message)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def verify(
|
|
399
|
+
public_key: bytes, context: bytes, message: bytes, signature: bytes
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Raises :class:`AuthenticationError` unless both halves verify.
|
|
402
|
+
|
|
403
|
+
Returns ``None`` on success rather than ``True``: a caller that forgets to
|
|
404
|
+
check a boolean would treat every failure as a pass.
|
|
405
|
+
"""
|
|
406
|
+
_check(
|
|
407
|
+
_b.lib.hide_verify_message(
|
|
408
|
+
public_key, len(public_key), context, len(context),
|
|
409
|
+
message, len(message), signature, len(signature),
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def new_challenge(audience: str, now: int, valid_for: int) -> bytes:
|
|
415
|
+
"""Creates a challenge for a prover to answer.
|
|
416
|
+
|
|
417
|
+
A detached signature proves possession at some point, to nobody in
|
|
418
|
+
particular, and can be replayed. A challenge binds a random nonce, an
|
|
419
|
+
audience and an expiry, so an answer is good once, here, now.
|
|
420
|
+
"""
|
|
421
|
+
out = _b.lib.hide_buffer_empty()
|
|
422
|
+
_check(
|
|
423
|
+
_b.lib.hide_challenge_new(
|
|
424
|
+
audience.encode(), now, valid_for, ctypes.byref(out)
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
return _take(out)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class SpentNonces:
|
|
431
|
+
"""The verifier's record of answered challenges.
|
|
432
|
+
|
|
433
|
+
Replay can only be detected by the verifier: a replayed answer is a
|
|
434
|
+
genuine signature and nothing about it is invalid on its own. This must
|
|
435
|
+
therefore outlive a single request.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
__slots__ = ("_handle",)
|
|
439
|
+
|
|
440
|
+
def __init__(self) -> None:
|
|
441
|
+
self._handle = ctypes.c_void_p(_b.lib.hide_spent_nonces_new())
|
|
442
|
+
if not self._handle:
|
|
443
|
+
raise HideError("could not allocate the nonce record")
|
|
444
|
+
|
|
445
|
+
def accept(
|
|
446
|
+
self, challenge: bytes, signature: bytes, public_key: bytes, now: int
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Accepts an answer exactly once.
|
|
449
|
+
|
|
450
|
+
Raises :class:`ChallengeReplayed` the second time, :class:`ChallengeExpired`
|
|
451
|
+
after the window, and :class:`AuthenticationError` if it does not verify.
|
|
452
|
+
"""
|
|
453
|
+
if not getattr(self, "_handle", None):
|
|
454
|
+
raise ValueError("this record has been closed")
|
|
455
|
+
_check(
|
|
456
|
+
_b.lib.hide_challenge_accept(
|
|
457
|
+
self._handle, challenge, len(challenge), signature, len(signature),
|
|
458
|
+
public_key, len(public_key), now,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def close(self) -> None:
|
|
463
|
+
if getattr(self, "_handle", None):
|
|
464
|
+
_b.lib.hide_spent_nonces_free(self._handle)
|
|
465
|
+
self._handle = None
|
|
466
|
+
|
|
467
|
+
def __enter__(self) -> "SpentNonces":
|
|
468
|
+
return self
|
|
469
|
+
|
|
470
|
+
def __exit__(self, *_: object) -> None:
|
|
471
|
+
self.close()
|
|
472
|
+
|
|
473
|
+
def __del__(self) -> None:
|
|
474
|
+
self.close()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""ctypes binding to the HIDE C core.
|
|
2
|
+
|
|
3
|
+
ctypes rather than a compiled extension: the wheel then works on any CPython
|
|
4
|
+
without a build step, and there is no second place where the ABI is described.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ctypes
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from ctypes.util import find_library
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
OK = 0
|
|
16
|
+
ERR_INVALID_ARGUMENT = 1
|
|
17
|
+
ERR_WRONG_PASSPHRASE = 2
|
|
18
|
+
ERR_NOT_A_KEY = 3
|
|
19
|
+
ERR_AUTHENTICATION = 4
|
|
20
|
+
ERR_NO_MATCHING_RECIPIENT = 5
|
|
21
|
+
ERR_MALFORMED = 6
|
|
22
|
+
ERR_TOO_LARGE = 7
|
|
23
|
+
ERR_CHALLENGE_EXPIRED = 8
|
|
24
|
+
ERR_CHALLENGE_REPLAYED = 9
|
|
25
|
+
|
|
26
|
+
KEY_RAW = 0
|
|
27
|
+
KEY_PROTECTED = 1
|
|
28
|
+
|
|
29
|
+
PUBLIC_KEY_LEN = 1216
|
|
30
|
+
MIN_PASSPHRASE_LEN = 8
|
|
31
|
+
SIGNATURE_LEN = 3373
|
|
32
|
+
VERIFYING_KEY_LEN = 1984
|
|
33
|
+
NONCE_LEN = 32
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Buffer(ctypes.Structure):
|
|
37
|
+
_fields_ = [
|
|
38
|
+
("data", ctypes.POINTER(ctypes.c_uint8)),
|
|
39
|
+
("len", ctypes.c_size_t),
|
|
40
|
+
("capacity", ctypes.c_size_t),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _library_names() -> list[str]:
|
|
45
|
+
if sys.platform == "win32":
|
|
46
|
+
return ["hide_ffi.dll"]
|
|
47
|
+
if sys.platform == "darwin":
|
|
48
|
+
return ["libhide_ffi.dylib"]
|
|
49
|
+
return ["libhide_ffi.so"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load() -> ctypes.CDLL:
|
|
53
|
+
"""Finds the shared library: bundled in the wheel first, then the system."""
|
|
54
|
+
here = Path(__file__).parent
|
|
55
|
+
candidates = [here / name for name in _library_names()]
|
|
56
|
+
|
|
57
|
+
# Set by developers running against a cargo build tree.
|
|
58
|
+
override = os.environ.get("HIDE_LIBRARY")
|
|
59
|
+
if override:
|
|
60
|
+
candidates.insert(0, Path(override))
|
|
61
|
+
|
|
62
|
+
for candidate in candidates:
|
|
63
|
+
if candidate.exists():
|
|
64
|
+
return ctypes.CDLL(str(candidate))
|
|
65
|
+
|
|
66
|
+
system = find_library("hide_ffi")
|
|
67
|
+
if system:
|
|
68
|
+
return ctypes.CDLL(system)
|
|
69
|
+
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"the HIDE native library was not found. Install a wheel that bundles "
|
|
72
|
+
"it, or set HIDE_LIBRARY to the path of "
|
|
73
|
+
f"{_library_names()[0]} built by `cargo build -p hide-ffi`."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
lib = _load()
|
|
78
|
+
|
|
79
|
+
_SIGNATURES = {
|
|
80
|
+
"hide_error_message": ([ctypes.c_int32], ctypes.c_char_p),
|
|
81
|
+
"hide_version": ([], ctypes.c_char_p),
|
|
82
|
+
"hide_buffer_empty": ([], Buffer),
|
|
83
|
+
"hide_buffer_free": ([ctypes.POINTER(Buffer)], None),
|
|
84
|
+
"hide_keypair_generate": (
|
|
85
|
+
[ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(Buffer)],
|
|
86
|
+
ctypes.c_int32,
|
|
87
|
+
),
|
|
88
|
+
"hide_inspect_key": (
|
|
89
|
+
[ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_int32)],
|
|
90
|
+
ctypes.c_int32,
|
|
91
|
+
),
|
|
92
|
+
"hide_secret_key_open": (
|
|
93
|
+
[
|
|
94
|
+
ctypes.c_char_p,
|
|
95
|
+
ctypes.c_size_t,
|
|
96
|
+
ctypes.c_char_p,
|
|
97
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
98
|
+
],
|
|
99
|
+
ctypes.c_int32,
|
|
100
|
+
),
|
|
101
|
+
"hide_secret_key_protect": (
|
|
102
|
+
[ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(Buffer)],
|
|
103
|
+
ctypes.c_int32,
|
|
104
|
+
),
|
|
105
|
+
"hide_secret_key_public": (
|
|
106
|
+
[ctypes.c_void_p, ctypes.POINTER(Buffer)],
|
|
107
|
+
ctypes.c_int32,
|
|
108
|
+
),
|
|
109
|
+
"hide_secret_key_free": ([ctypes.c_void_p], None),
|
|
110
|
+
"hide_public_key_armor": (
|
|
111
|
+
[ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(Buffer)],
|
|
112
|
+
ctypes.c_int32,
|
|
113
|
+
),
|
|
114
|
+
"hide_public_key_dearmor": (
|
|
115
|
+
[ctypes.c_char_p, ctypes.POINTER(Buffer)],
|
|
116
|
+
ctypes.c_int32,
|
|
117
|
+
),
|
|
118
|
+
"hide_encrypt": (
|
|
119
|
+
[
|
|
120
|
+
ctypes.c_char_p,
|
|
121
|
+
ctypes.c_size_t,
|
|
122
|
+
ctypes.c_char_p,
|
|
123
|
+
ctypes.c_size_t,
|
|
124
|
+
ctypes.c_char_p,
|
|
125
|
+
ctypes.c_char_p,
|
|
126
|
+
ctypes.POINTER(Buffer),
|
|
127
|
+
],
|
|
128
|
+
ctypes.c_int32,
|
|
129
|
+
),
|
|
130
|
+
"hide_decrypt": (
|
|
131
|
+
[
|
|
132
|
+
ctypes.c_char_p,
|
|
133
|
+
ctypes.c_size_t,
|
|
134
|
+
ctypes.c_void_p,
|
|
135
|
+
ctypes.POINTER(Buffer),
|
|
136
|
+
ctypes.POINTER(Buffer),
|
|
137
|
+
ctypes.POINTER(Buffer),
|
|
138
|
+
],
|
|
139
|
+
ctypes.c_int32,
|
|
140
|
+
),
|
|
141
|
+
"hide_identity_generate": (
|
|
142
|
+
[ctypes.c_char_p, ctypes.POINTER(Buffer)],
|
|
143
|
+
ctypes.c_int32,
|
|
144
|
+
),
|
|
145
|
+
"hide_signing_identity_open": (
|
|
146
|
+
[
|
|
147
|
+
ctypes.c_char_p,
|
|
148
|
+
ctypes.c_size_t,
|
|
149
|
+
ctypes.c_char_p,
|
|
150
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
151
|
+
],
|
|
152
|
+
ctypes.c_int32,
|
|
153
|
+
),
|
|
154
|
+
"hide_signing_identity_public": (
|
|
155
|
+
[ctypes.c_void_p, ctypes.POINTER(Buffer)],
|
|
156
|
+
ctypes.c_int32,
|
|
157
|
+
),
|
|
158
|
+
"hide_signing_identity_free": ([ctypes.c_void_p], None),
|
|
159
|
+
"hide_sign_message": (
|
|
160
|
+
[
|
|
161
|
+
ctypes.c_void_p,
|
|
162
|
+
ctypes.c_char_p,
|
|
163
|
+
ctypes.c_size_t,
|
|
164
|
+
ctypes.c_char_p,
|
|
165
|
+
ctypes.c_size_t,
|
|
166
|
+
ctypes.POINTER(Buffer),
|
|
167
|
+
],
|
|
168
|
+
ctypes.c_int32,
|
|
169
|
+
),
|
|
170
|
+
"hide_verify_message": (
|
|
171
|
+
[
|
|
172
|
+
ctypes.c_char_p,
|
|
173
|
+
ctypes.c_size_t,
|
|
174
|
+
ctypes.c_char_p,
|
|
175
|
+
ctypes.c_size_t,
|
|
176
|
+
ctypes.c_char_p,
|
|
177
|
+
ctypes.c_size_t,
|
|
178
|
+
ctypes.c_char_p,
|
|
179
|
+
ctypes.c_size_t,
|
|
180
|
+
],
|
|
181
|
+
ctypes.c_int32,
|
|
182
|
+
),
|
|
183
|
+
"hide_challenge_new": (
|
|
184
|
+
[ctypes.c_char_p, ctypes.c_uint64, ctypes.c_uint64, ctypes.POINTER(Buffer)],
|
|
185
|
+
ctypes.c_int32,
|
|
186
|
+
),
|
|
187
|
+
"hide_challenge_answer": (
|
|
188
|
+
[ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(Buffer)],
|
|
189
|
+
ctypes.c_int32,
|
|
190
|
+
),
|
|
191
|
+
"hide_spent_nonces_new": ([], ctypes.c_void_p),
|
|
192
|
+
"hide_spent_nonces_free": ([ctypes.c_void_p], None),
|
|
193
|
+
"hide_challenge_accept": (
|
|
194
|
+
[
|
|
195
|
+
ctypes.c_void_p,
|
|
196
|
+
ctypes.c_char_p,
|
|
197
|
+
ctypes.c_size_t,
|
|
198
|
+
ctypes.c_char_p,
|
|
199
|
+
ctypes.c_size_t,
|
|
200
|
+
ctypes.c_char_p,
|
|
201
|
+
ctypes.c_size_t,
|
|
202
|
+
ctypes.c_uint64,
|
|
203
|
+
],
|
|
204
|
+
ctypes.c_int32,
|
|
205
|
+
),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for _name, (_argtypes, _restype) in _SIGNATURES.items():
|
|
209
|
+
_function = getattr(lib, _name)
|
|
210
|
+
_function.argtypes = _argtypes
|
|
211
|
+
_function.restype = _restype
|
|
Binary file
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hide-protocol
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Experimental hybrid post-quantum file and message encryption (X25519 + ML-KEM-768). Unaudited.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hide-protocol/hide
|
|
6
|
+
Project-URL: Source, https://github.com/hide-protocol/hide
|
|
7
|
+
Project-URL: Issues, https://github.com/hide-protocol/hide/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cryptography,encryption,hpke,ml-kem,post-quantum
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Rust
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# HIDE Protocol — 0.5, experimental
|
|
20
|
+
|
|
21
|
+
*Human-friendly Identity & Data Encryption.* The goal is to encrypt to a person, not to a key.
|
|
22
|
+
This repository implements the **file format engine** and a **hybrid signature scheme** on top of it:
|
|
23
|
+
one identity can encrypt, sign a file, prove possession to a live verifier, and act as an ssh-agent.
|
|
24
|
+
|
|
25
|
+
> **Do not use this for sensitive data.** The protocol is a draft, the code is unaudited, no
|
|
26
|
+
> external security review has happened, and the hybrid KEM tracks a moving IETF draft.
|
|
27
|
+
|
|
28
|
+
## What is verified today
|
|
29
|
+
|
|
30
|
+
Every claim below was produced by a command in this repository, on Rust 1.97.1.
|
|
31
|
+
|
|
32
|
+
- Encrypt/decrypt round-trips across chunk boundaries (0 B, 1 B, 64 KiB ± 1, multi-chunk).
|
|
33
|
+
- One payload, many recipients: the file is encrypted once; only the content key is wrapped per recipient.
|
|
34
|
+
- Tamper detection: flipping **any single byte** of a container makes decryption fail (`cargo test -p hide-object --test vectors`).
|
|
35
|
+
- Truncation, chunk reordering, duplication, deletion and trailing bytes are all rejected.
|
|
36
|
+
- **Independent interoperability**: a separate Node implementation (`@hpke/hybridkem-x-wing`, `cbor`,
|
|
37
|
+
Node `crypto`) decrypts the Rust vectors, and Rust decrypts Node's container byte-identically.
|
|
38
|
+
- **Cross-OS**: the full suite passes on Windows 11 and on Linux (WSL2 Ubuntu 24.04), and a Linux
|
|
39
|
+
build opens a container produced on Windows.
|
|
40
|
+
- Degenerate recipient keys are refused: an X25519 component of small order would silently remove the
|
|
41
|
+
classical half of the hybrid, so all seven such points are rejected before use.
|
|
42
|
+
- Property tests (`proptest`) assert the parser never panics on arbitrary input, that any single-byte
|
|
43
|
+
mutation of a container fails to decrypt, and that truncation or appended bytes always fail.
|
|
44
|
+
- **Hybrid signatures**: Ed25519 + ML-DSA-65, concatenated; a signature verifies only if **both**
|
|
45
|
+
halves do, so neither a quantum nor a classical break of one is enough.
|
|
46
|
+
- **Signatures cross surfaces**: a signature made in WASM verifies in Node and vice versa, and every
|
|
47
|
+
SDK returns the same verdict on the same bytes (`node conformance/cross-surface/verify.mjs`).
|
|
48
|
+
- **OpenSSH accepts our agent**: `ssh-add -l` lists the key, `ssh-keygen -Y sign` obtains a signature
|
|
49
|
+
through it, and `ssh-keygen -Y verify` reports it good — verified by OpenSSH's own tools, not ours.
|
|
50
|
+
- **Replay is refused**: a challenge answer is accepted once; presenting the identical valid signature
|
|
51
|
+
again is rejected, as is one given for a different audience or after its expiry.
|
|
52
|
+
|
|
53
|
+
## Measured performance
|
|
54
|
+
|
|
55
|
+
On this machine (release build, 256 MiB payload):
|
|
56
|
+
|
|
57
|
+
| Metric | Value |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| Encrypt / decrypt (in memory) | ~1250 / ~1550 MiB/s |
|
|
60
|
+
| Peak RSS for a 256 MB file | **7 MB** — constant, independent of input size |
|
|
61
|
+
| Size overhead | 0.033% (~85 KB, dominated by the 1120-byte hybrid encapsulation) |
|
|
62
|
+
| `hide.exe` | 675 KB |
|
|
63
|
+
|
|
64
|
+
Streaming reuses two fixed 64 KiB buffers and one expanded AEAD instance, so there is no per-chunk
|
|
65
|
+
allocation or rekeying. `cargo run --release -p hide-object --example throughput` reproduces the numbers.
|
|
66
|
+
|
|
67
|
+
## What is NOT implemented or guaranteed
|
|
68
|
+
|
|
69
|
+
Being explicit here matters more than the feature list.
|
|
70
|
+
|
|
71
|
+
- **No identity, directory or key transparency.** Recipients are raw test key files that you must
|
|
72
|
+
exchange over a channel you already trust. Nothing proves a key belongs to a particular person.
|
|
73
|
+
- **Sender authentication only when the container is signed.** For an unsigned container, a successful
|
|
74
|
+
decryption proves it was not altered; it does **not** prove who created it. A signed container binds
|
|
75
|
+
a signing key to the recipient set, the metadata and the exact plaintext — but it attests to a *key*,
|
|
76
|
+
and nothing yet proves that key belongs to a particular person.
|
|
77
|
+
- **No forward secrecy** for stored objects: anyone who later obtains the recipient secret can decrypt
|
|
78
|
+
previously captured containers. Device revocation cannot retroactively protect data an attacker already holds.
|
|
79
|
+
- **No hardware protection.** Secret keys are sealed with a passphrase (Argon2id + ChaCha20-Poly1305),
|
|
80
|
+
but there is no Keychain, TPM, Secure Enclave or Keystore integration, and `--insecure-plaintext`
|
|
81
|
+
still writes an unencrypted key on request.
|
|
82
|
+
- **Recipient privacy is limited.** Stanzas carry no identifiers, but the recipient *count* and the
|
|
83
|
+
ciphertext size are visible, and metadata is encrypted rather than hidden. A *public* signature also
|
|
84
|
+
reveals the signer's key to anyone holding the file; the confidential placement avoids this.
|
|
85
|
+
- **Signing is not streaming.** A signature commits to the plaintext, so signing buffers the payload.
|
|
86
|
+
- **SSH authentication is not post-quantum.** `hide agent` offers the Ed25519 half of an identity and
|
|
87
|
+
nothing more. OpenSSH accepts only `ssh-ed25519`, `sk-*` and RSA for user authentication;
|
|
88
|
+
post-quantum algorithms exist there only in key exchange. What this buys is one sealed identity
|
|
89
|
+
instead of a plaintext private key sitting in `~/.ssh`, not quantum resistance.
|
|
90
|
+
- **An agent is a signing oracle.** Anything that can reach the endpoint can ask for a signature.
|
|
91
|
+
That is why confirmation is the default and `--no-confirm` must be asked for.
|
|
92
|
+
- Not yet built: identity state, device enrollment, recovery, revocation, key transparency,
|
|
93
|
+
MLS messaging.
|
|
94
|
+
|
|
95
|
+
## Download
|
|
96
|
+
|
|
97
|
+
Releases carry three kinds of build. Verify any download against `SHA256SUMS` first.
|
|
98
|
+
|
|
99
|
+
| Build | File | Use it when |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| Desktop application | `HIDE_*-setup.exe`, `*.dmg`, `*.deb`, `*.AppImage` | You want a window, not a terminal. |
|
|
102
|
+
| Portable | `hide-portable-*` | You want one executable, no installation, keys kept beside it. |
|
|
103
|
+
| Command line | `hide-*` | You want to script it. |
|
|
104
|
+
|
|
105
|
+
The portable build writes nothing outside its own folder: keys go into a `hide-keys` directory
|
|
106
|
+
next to the executable, so it runs from a USB stick and leaves no trace in your user profile.
|
|
107
|
+
|
|
108
|
+
### Platforms
|
|
109
|
+
|
|
110
|
+
The CLI is built for Linux (x86-64, ARM64, and a static musl build for Alpine and
|
|
111
|
+
scratch containers), Windows (x86-64, ARM64) and macOS (Apple silicon, Intel).
|
|
112
|
+
|
|
113
|
+
### Package managers
|
|
114
|
+
|
|
115
|
+
Manifests for Homebrew, Scoop, WinGet and the AUR live in [`packaging/`](packaging/) and are
|
|
116
|
+
generated with the real checksums by the release workflow. None is published yet: putting an
|
|
117
|
+
unaudited encryption tool in a default package manager reaches people who will not read the
|
|
118
|
+
warnings, so that step is taken deliberately rather than automatically.
|
|
119
|
+
|
|
120
|
+
## SDKs
|
|
121
|
+
|
|
122
|
+
Every binding calls the same Rust core through one C ABI ([`crates/hide-ffi`](crates/hide-ffi)).
|
|
123
|
+
No language reimplements the cryptography, so there is a single implementation to review, and
|
|
124
|
+
[`conformance/cross-surface`](conformance/cross-surface) asserts that what one surface produces
|
|
125
|
+
every other surface can open.
|
|
126
|
+
|
|
127
|
+
| Language | Path | How it binds |
|
|
128
|
+
| --- | --- | --- |
|
|
129
|
+
| C / C++ | [`crates/hide-ffi/include/hide.h`](crates/hide-ffi/include/hide.h) | The ABI itself |
|
|
130
|
+
| Python | [`sdk/python`](sdk/python) | `ctypes`, so a wheel needs no compiler |
|
|
131
|
+
| TypeScript / Node | [`sdk/node`](sdk/node) | `koffi` over the same shared library |
|
|
132
|
+
| Browser | [`sdk/wasm`](sdk/wasm) | WebAssembly, compiled from the same crates |
|
|
133
|
+
| Go | [`sdk/go`](sdk/go) | `cgo` |
|
|
134
|
+
| Java / Kotlin | [`sdk/java`](sdk/java) | Foreign Function & Memory API, no JNI shim |
|
|
135
|
+
| Ruby | [`sdk/ruby`](sdk/ruby) | stdlib `fiddle`, no native gem to build |
|
|
136
|
+
| PHP | [`sdk/php`](sdk/php) | `ext-ffi` |
|
|
137
|
+
| .NET / C# | [`sdk/dotnet`](sdk/dotnet) | Source-generated `LibraryImport` |
|
|
138
|
+
|
|
139
|
+
Secret keys never cross into the host language: each SDK holds an opaque handle, and there is
|
|
140
|
+
deliberately no function that exports key material.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import hide_protocol as hide
|
|
144
|
+
|
|
145
|
+
with hide.SecretKey.generate() as secret:
|
|
146
|
+
box = hide.encrypt(b"hello", [secret.public_key()])
|
|
147
|
+
assert hide.decrypt(box, secret).data == b"hello"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A browser is a weaker place to hold a key than a desktop: any script on the page shares the
|
|
151
|
+
heap, so an XSS bug is equivalent to key theft. Prefer the CLI or the desktop application for
|
|
152
|
+
keys that matter.
|
|
153
|
+
|
|
154
|
+
## Try it
|
|
155
|
+
|
|
156
|
+
```powershell
|
|
157
|
+
cargo test --workspace --all-features
|
|
158
|
+
|
|
159
|
+
# A key pair. The secret is sealed with a passphrase unless you opt out.
|
|
160
|
+
cargo run -p hide-cli -- --experimental keygen --secret alice.hide-key --public alice.hide-pub
|
|
161
|
+
|
|
162
|
+
# Files.
|
|
163
|
+
cargo run -p hide-cli -- --experimental encrypt report.pdf --recipient alice.hide-pub --output report.pdf.hide
|
|
164
|
+
cargo run -p hide-cli -- --experimental open report.pdf.hide --secret alice.hide-key --output report.pdf
|
|
165
|
+
|
|
166
|
+
# Sign as you encrypt. The signature is readable only by the recipients unless
|
|
167
|
+
# you pass --public-signature.
|
|
168
|
+
cargo run -p hide-cli -- --experimental encrypt report.pdf --recipient alice.hide-pub --output report.pdf.hide --sign alice.hide-key
|
|
169
|
+
|
|
170
|
+
# Or sign a file in place, leaving report.pdf.hide-sig beside it.
|
|
171
|
+
cargo run -p hide-cli -- --experimental sign report.pdf --secret alice.hide-key
|
|
172
|
+
cargo run -p hide-cli -- --experimental verify report.pdf --signer alice.hide-pub.sign
|
|
173
|
+
|
|
174
|
+
# Text messages, as a block you can paste into email or chat.
|
|
175
|
+
cargo run -p hide-cli -- --experimental seal "meet at six" --recipient alice.hide-pub
|
|
176
|
+
cargo run -p hide-cli -- --experimental unseal message.txt --secret alice.hide-key
|
|
177
|
+
|
|
178
|
+
# What is this file? Answered without decrypting it.
|
|
179
|
+
cargo run -p hide-cli -- --experimental info report.pdf.hide
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The CLI never overwrites an existing file, writes plaintext to private staging first, and publishes the
|
|
183
|
+
result only after authentication succeeds. `--experimental` is mandatory, so the risk is acknowledged explicitly.
|
|
184
|
+
|
|
185
|
+
`keygen` writes three files: one secret master seed, and two shareable public keys — `alice.hide-pub`
|
|
186
|
+
for encryption and `alice.hide-pub.sign` for checking signatures. Both derive from the master seed, so
|
|
187
|
+
there is a single thing to back up, and neither can be computed from the other. A key file created
|
|
188
|
+
before signatures existed still decrypts; signing with it fails and says so.
|
|
189
|
+
|
|
190
|
+
A signature proves possession of a key. HIDE has no directory or transparency log, so nothing ties that
|
|
191
|
+
key to a person — compare a signer's key against one you already trust.
|
|
192
|
+
|
|
193
|
+
### Logging in over SSH
|
|
194
|
+
|
|
195
|
+
The same identity can act as an ssh-agent, so the key that authenticates you is never written to disk
|
|
196
|
+
in the clear.
|
|
197
|
+
|
|
198
|
+
```powershell
|
|
199
|
+
# Print the public line to paste into ~/.ssh/authorized_keys or github.com/settings/keys.
|
|
200
|
+
cargo run -p hide-cli -- --experimental ssh-key --secret alice.hide-key
|
|
201
|
+
|
|
202
|
+
# Serve it. Every signature asks for confirmation unless you pass --no-confirm.
|
|
203
|
+
cargo run -p hide-cli -- --experimental agent --secret alice.hide-key
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Then point SSH at it with `SSH_AUTH_SOCK` — the socket path on Unix, the pipe
|
|
207
|
+
path on Windows (`$env:SSH_AUTH_SOCK = '\\.\pipe\hide-agent'`). OpenSSH for
|
|
208
|
+
Windows 9.5p2 ignores `-o IdentityAgent`, so use the environment variable on
|
|
209
|
+
both platforms.
|
|
210
|
+
|
|
211
|
+
This offers the Ed25519 half of the identity only. SSH cannot carry the post-quantum half, so an SSH
|
|
212
|
+
login is not post-quantum; what it avoids is a plaintext private key on disk. Treat the endpoint as
|
|
213
|
+
sensitive: anything that can reach it can ask for a signature.
|
|
214
|
+
|
|
215
|
+
### Building the desktop application
|
|
216
|
+
|
|
217
|
+
```powershell
|
|
218
|
+
cd apps/hide-desktop
|
|
219
|
+
pnpm install --ignore-workspace
|
|
220
|
+
pnpm tauri build # installer
|
|
221
|
+
pnpm build:portable # single portable executable
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The application calls the same Rust crates as the CLI; it contains no separate cryptographic code.
|
|
225
|
+
Key material never reaches the user interface layer. A test in `src-tauri/tests/interop.rs` asserts
|
|
226
|
+
that each surface can open what the other produced, so they cannot silently diverge.
|
|
227
|
+
|
|
228
|
+
## Repository layout
|
|
229
|
+
|
|
230
|
+
| Path | Purpose |
|
|
231
|
+
| --- | --- |
|
|
232
|
+
| `crates/hide-format` | Preamble, bounded canonical CBOR, portable-filename metadata |
|
|
233
|
+
| `crates/hide-crypto` | HPKE X-Wing wrapping, HKDF, HMAC, ChaCha20-Poly1305; secrets zeroize and cannot be printed |
|
|
234
|
+
| `crates/hide-object` | Envelope encryption and authenticated 64 KiB streaming |
|
|
235
|
+
| `crates/hide-keyring` | Passphrase-sealed key files (Argon2id) and public-key armor |
|
|
236
|
+
| `crates/hide-ffi` | The C ABI every language binding calls |
|
|
237
|
+
| `crates/hide-wasm` | WebAssembly bindings for the browser |
|
|
238
|
+
| `apps/hide-cli` | `hide` binary |
|
|
239
|
+
| `apps/hide-desktop` | Desktop application (Tauri) and the portable build |
|
|
240
|
+
| `sdk/` | Python, Node, WASM, Go and Java packages |
|
|
241
|
+
| `packaging/` | Homebrew, Scoop, WinGet and AUR manifests |
|
|
242
|
+
| `conformance/` | Frozen vectors plus the independent Node verifier |
|
|
243
|
+
| `spec/hide-0.1.md` | Wire format |
|
|
244
|
+
|
|
245
|
+
## Cryptography
|
|
246
|
+
|
|
247
|
+
Suite 1 is HPKE base mode with the X-Wing hybrid KEM (X25519 + ML-KEM-768), HKDF-SHA256 and
|
|
248
|
+
ChaCha20-Poly1305, via the `hpke` and RustCrypto crates. No primitive is implemented here. Because
|
|
249
|
+
X-Wing and HPKE-PQ are still drafts, the wire format is pinned to exact dependency versions and will
|
|
250
|
+
change; vectors will be regenerated when the upstream construction changes.
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
Apache-2.0 — the specification and vectors are freely implementable, with no requirement to use any
|
|
255
|
+
particular server or service.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
hide_protocol/__init__.py,sha256=Mla-iH7trtECVQeOPOcl6HdBBi9_taFPolqyp-7XEUg,14284
|
|
2
|
+
hide_protocol/_binding.py,sha256=jLNM5zNeBnm2nBGOJ1wCiTB2br8YwOvkD_EWdpBbkUs,5650
|
|
3
|
+
hide_protocol/hide_ffi.dll,sha256=Km9oDaAE9lL6jT6nKXT8-adE2nlHf4nIlc3d7dHjlT0,560128
|
|
4
|
+
hide_protocol-0.5.0.dist-info/METADATA,sha256=dmuEf2bMLmnHl2jeVQt31jEJRioCm_cBAIenyaYkK9g,13765
|
|
5
|
+
hide_protocol-0.5.0.dist-info/WHEEL,sha256=ntsWXhYJu-JTQqohzBZIuALw3B9yb-GeNPKXq8syg68,94
|
|
6
|
+
hide_protocol-0.5.0.dist-info/licenses/LICENSE,sha256=6NeX9WVPnytB3FOpmTvP2VYkHcqDzpogSwoYArMdgA4,766
|
|
7
|
+
hide_protocol-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
16
|
+
|
|
17
|
+
The full license text is available at the URL above.
|