hpqc 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.
- hpqc/__init__.py +13 -0
- hpqc/bacap/__init__.py +64 -0
- hpqc/bacap/exceptions.py +63 -0
- hpqc/bacap/stateful.py +189 -0
- hpqc/bacap/stateless.py +361 -0
- hpqc/hash.py +19 -0
- hpqc/kem/__init__.py +8 -0
- hpqc/kem/mkem.py +210 -0
- hpqc/nike/__init__.py +32 -0
- hpqc/nike/_ctidh.py +118 -0
- hpqc/nike/ctidh1024.py +14 -0
- hpqc/nike/ctidh2048.py +14 -0
- hpqc/nike/ctidh511.py +14 -0
- hpqc/nike/ctidh512.py +14 -0
- hpqc/nike/hybrid.py +209 -0
- hpqc/nike/scheme.py +124 -0
- hpqc/nike/x25519.py +91 -0
- hpqc/py.typed +0 -0
- hpqc/sign/__init__.py +2 -0
- hpqc/sign/ed25519/__init__.py +10 -0
- hpqc/sign/ed25519/blinded25519.py +167 -0
- hpqc-0.0.1.dist-info/METADATA +156 -0
- hpqc-0.0.1.dist-info/RECORD +25 -0
- hpqc-0.0.1.dist-info/WHEEL +4 -0
- hpqc-0.0.1.dist-info/licenses/LICENSE +661 -0
hpqc/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 David Stainton
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
"""hpqc: Python port of katzenpost/hpqc."""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = _pkg_version("hpqc")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
# Running from a source checkout without an install (e.g.
|
|
11
|
+
# someone imported this directly off disk). The released
|
|
12
|
+
# version is the one in py/pyproject.toml.
|
|
13
|
+
__version__ = "0.0.0+unknown"
|
hpqc/bacap/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 David Stainton
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
"""Python port of katzenpost/hpqc/bacap.
|
|
4
|
+
|
|
5
|
+
The package exposes two complementary APIs:
|
|
6
|
+
|
|
7
|
+
- **Stateless** (``hpqc.bacap.stateless``): ``MessageBoxIndex``,
|
|
8
|
+
``WriteCap`` and ``ReadCap`` are pure values; every cryptographic
|
|
9
|
+
operation is a method that takes its inputs and returns its
|
|
10
|
+
outputs without mutating the caller's state. Suitable for callers
|
|
11
|
+
that already manage the per-conversation state themselves and want
|
|
12
|
+
a thin layer over the BACAP primitives.
|
|
13
|
+
|
|
14
|
+
- **Stateful** (``hpqc.bacap.stateful``): ``StatefulReader`` and
|
|
15
|
+
``StatefulWriter`` are mutable wrappers built on the stateless
|
|
16
|
+
layer. They carry the next-index pointer and advance it after each
|
|
17
|
+
successful read or write, mirroring the Go API.
|
|
18
|
+
|
|
19
|
+
Both APIs sit on the same primitives and produce byte-identical
|
|
20
|
+
output; pick whichever shape suits your application.
|
|
21
|
+
"""
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
BACAPError,
|
|
24
|
+
BoxIDMismatch,
|
|
25
|
+
CannotRewind,
|
|
26
|
+
DecryptionFailed,
|
|
27
|
+
EmptyBox,
|
|
28
|
+
InvalidArgument,
|
|
29
|
+
SignatureVerificationFailed,
|
|
30
|
+
)
|
|
31
|
+
from .stateless import (
|
|
32
|
+
BoxIDSize,
|
|
33
|
+
MessageBoxIndex,
|
|
34
|
+
MessageBoxIndexSize,
|
|
35
|
+
ReadCap,
|
|
36
|
+
ReadCapSize,
|
|
37
|
+
SignatureSize,
|
|
38
|
+
WriteCap,
|
|
39
|
+
WriteCapSize,
|
|
40
|
+
)
|
|
41
|
+
from .stateful import StatefulReader, StatefulWriter
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# exceptions
|
|
45
|
+
"BACAPError",
|
|
46
|
+
"BoxIDMismatch",
|
|
47
|
+
"CannotRewind",
|
|
48
|
+
"DecryptionFailed",
|
|
49
|
+
"EmptyBox",
|
|
50
|
+
"InvalidArgument",
|
|
51
|
+
"SignatureVerificationFailed",
|
|
52
|
+
# stateless
|
|
53
|
+
"BoxIDSize",
|
|
54
|
+
"MessageBoxIndex",
|
|
55
|
+
"MessageBoxIndexSize",
|
|
56
|
+
"ReadCap",
|
|
57
|
+
"ReadCapSize",
|
|
58
|
+
"SignatureSize",
|
|
59
|
+
"WriteCap",
|
|
60
|
+
"WriteCapSize",
|
|
61
|
+
# stateful
|
|
62
|
+
"StatefulReader",
|
|
63
|
+
"StatefulWriter",
|
|
64
|
+
]
|
hpqc/bacap/exceptions.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 David Stainton
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
"""Exception hierarchy for the BACAP API.
|
|
4
|
+
|
|
5
|
+
All exceptions raised from ``hpqc.bacap`` derive from ``BACAPError``,
|
|
6
|
+
so callers can catch the entire surface with a single ``except``. The
|
|
7
|
+
distinct subclasses let a caller act on the specific failure mode:
|
|
8
|
+
|
|
9
|
+
- ``InvalidArgument``: caller-supplied input is malformed (wrong
|
|
10
|
+
size, wrong type, out of range, ``None`` where required, or the
|
|
11
|
+
object is in a state that does not permit the requested operation).
|
|
12
|
+
A bug on the caller's side.
|
|
13
|
+
|
|
14
|
+
- ``CannotRewind``: caller tried to advance a ``MessageBoxIndex`` to
|
|
15
|
+
an earlier position. Specific subclass of ``InvalidArgument``
|
|
16
|
+
because rewind is a recurring, recognisable misuse worth catching
|
|
17
|
+
on its own.
|
|
18
|
+
|
|
19
|
+
- ``EmptyBox``: a stateful reader was handed a zero-byte box ID,
|
|
20
|
+
indicating no message has been deposited at that index yet.
|
|
21
|
+
Typically a transient condition rather than a protocol violation.
|
|
22
|
+
|
|
23
|
+
- ``BoxIDMismatch``: the box ID supplied to a stateful reader does
|
|
24
|
+
not match the box ID it expected next. The caller may have read
|
|
25
|
+
out of order, or the message may belong to a different
|
|
26
|
+
conversation.
|
|
27
|
+
|
|
28
|
+
- ``SignatureVerificationFailed``: a BACAP signature did not verify
|
|
29
|
+
under the expected box-ID public key. Always a protocol failure
|
|
30
|
+
or tampering.
|
|
31
|
+
|
|
32
|
+
- ``DecryptionFailed``: AES-256-GCM-SIV authentication failed at
|
|
33
|
+
decryption time. Always a protocol failure or tampering.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BACAPError(Exception):
|
|
39
|
+
"""Base class for all BACAP-specific exceptions."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InvalidArgument(BACAPError):
|
|
43
|
+
"""Caller supplied invalid input (wrong size/type, None, bad state)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CannotRewind(InvalidArgument):
|
|
47
|
+
"""Attempted to advance a MessageBoxIndex to an earlier position."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EmptyBox(BACAPError):
|
|
51
|
+
"""Box ID is all-zero; no message available at this index."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BoxIDMismatch(BACAPError):
|
|
55
|
+
"""Received box ID does not match the expected box ID."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SignatureVerificationFailed(BACAPError):
|
|
59
|
+
"""Signature did not verify under the expected box-ID public key."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DecryptionFailed(BACAPError):
|
|
63
|
+
"""AES-256-GCM-SIV authentication failed at decryption time."""
|
hpqc/bacap/stateful.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 David Stainton
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
"""Stateful BACAP wrappers built on the stateless layer.
|
|
4
|
+
|
|
5
|
+
StatefulReader and StatefulWriter carry a mutable next-index pointer
|
|
6
|
+
and advance it after each successful read or write. They sit on the
|
|
7
|
+
stateless API in stateless.py; everything they do is expressible as a
|
|
8
|
+
sequence of stateless calls. Pick whichever shape suits your caller.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
BoxIDMismatch,
|
|
16
|
+
EmptyBox,
|
|
17
|
+
InvalidArgument,
|
|
18
|
+
)
|
|
19
|
+
from .stateless import (
|
|
20
|
+
BoxIDSize,
|
|
21
|
+
MessageBoxIndex,
|
|
22
|
+
ReadCap,
|
|
23
|
+
SignatureSize,
|
|
24
|
+
WriteCap,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StatefulReader:
|
|
29
|
+
"""Sequential reader over a BACAP ReadCap.
|
|
30
|
+
|
|
31
|
+
Holds the next-index pointer and the conversation context, advancing
|
|
32
|
+
the pointer after each successful decrypt_next.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
read_cap: ReadCap,
|
|
38
|
+
ctx: bytes,
|
|
39
|
+
next_index: Optional[MessageBoxIndex] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
if read_cap is None:
|
|
42
|
+
raise InvalidArgument("read_cap is None")
|
|
43
|
+
if ctx is None:
|
|
44
|
+
raise InvalidArgument("ctx is None")
|
|
45
|
+
self.read_cap: ReadCap = read_cap
|
|
46
|
+
# Defensive copy of ctx so callers cannot mutate it under us.
|
|
47
|
+
self.ctx: bytes = bytes(ctx)
|
|
48
|
+
if next_index is None:
|
|
49
|
+
# Start at the conversation's first message box.
|
|
50
|
+
self.last_inbox_read: Optional[MessageBoxIndex] = read_cap.first_message_box_index
|
|
51
|
+
self.next_index: Optional[MessageBoxIndex] = read_cap.first_message_box_index
|
|
52
|
+
else:
|
|
53
|
+
# Resuming from a known checkpoint or starting fresh from a
|
|
54
|
+
# specific index; this instance has no prior-read history.
|
|
55
|
+
self.last_inbox_read = None
|
|
56
|
+
self.next_index = next_index
|
|
57
|
+
|
|
58
|
+
def next_box_id(self) -> bytes:
|
|
59
|
+
"""Returns the box ID this reader will read next, without advancing."""
|
|
60
|
+
if self.next_index is not None:
|
|
61
|
+
cur = self.next_index
|
|
62
|
+
else:
|
|
63
|
+
if self.last_inbox_read is None:
|
|
64
|
+
raise InvalidArgument("both next_index and last_inbox_read are None")
|
|
65
|
+
cur = self.last_inbox_read.next_index()
|
|
66
|
+
return cur.box_id_for_context(self.read_cap, self.ctx)
|
|
67
|
+
|
|
68
|
+
def get_current_message_index(self) -> Optional[MessageBoxIndex]:
|
|
69
|
+
"""Returns the MessageBoxIndex that will be used for the next read."""
|
|
70
|
+
return self.next_index
|
|
71
|
+
|
|
72
|
+
def get_next_message_index(self) -> MessageBoxIndex:
|
|
73
|
+
"""Returns what next_index will be after a successful decrypt_next.
|
|
74
|
+
|
|
75
|
+
Does NOT advance state; useful for crash-consistency planning.
|
|
76
|
+
"""
|
|
77
|
+
if self.next_index is None:
|
|
78
|
+
raise InvalidArgument("next index is None")
|
|
79
|
+
return self.next_index.next_index()
|
|
80
|
+
|
|
81
|
+
def decrypt_next(
|
|
82
|
+
self,
|
|
83
|
+
ctx: bytes,
|
|
84
|
+
box: bytes,
|
|
85
|
+
ciphertext: bytes,
|
|
86
|
+
signature: bytes,
|
|
87
|
+
) -> bytes:
|
|
88
|
+
"""Verifies, decrypts, and advances state on success.
|
|
89
|
+
|
|
90
|
+
``ctx`` is the decryption context (passed through to the stateless
|
|
91
|
+
primitive); ``self.ctx`` is the box-ID context. They are the same
|
|
92
|
+
in normal use but the API allows them to differ, mirroring Go.
|
|
93
|
+
"""
|
|
94
|
+
if len(box) != BoxIDSize:
|
|
95
|
+
raise InvalidArgument("invalid box length")
|
|
96
|
+
if all(b == 0 for b in box):
|
|
97
|
+
raise EmptyBox("empty box, no message received")
|
|
98
|
+
if self.next_index is None:
|
|
99
|
+
raise InvalidArgument("next index is None, cannot parse reply")
|
|
100
|
+
if len(signature) != SignatureSize:
|
|
101
|
+
raise InvalidArgument("invalid signature length")
|
|
102
|
+
|
|
103
|
+
expected = self.next_index.box_id_for_context(self.read_cap, self.ctx)
|
|
104
|
+
if box != expected:
|
|
105
|
+
raise BoxIDMismatch("reply does not match expected box ID")
|
|
106
|
+
|
|
107
|
+
# Do everything that can fail BEFORE mutating state.
|
|
108
|
+
plaintext = self.next_index.decrypt_for_context(box, ctx, ciphertext, signature)
|
|
109
|
+
next_idx = self.next_index.next_index()
|
|
110
|
+
|
|
111
|
+
self.last_inbox_read = self.next_index
|
|
112
|
+
self.next_index = next_idx
|
|
113
|
+
return plaintext
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class StatefulWriter:
|
|
117
|
+
"""Sequential writer over a BACAP WriteCap.
|
|
118
|
+
|
|
119
|
+
Splits the per-message work into prepare_next (encrypt without
|
|
120
|
+
advancing) and advance_state (advance after the courier acks); the
|
|
121
|
+
convenience method encrypt_next does both.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
owner: WriteCap,
|
|
127
|
+
ctx: bytes,
|
|
128
|
+
next_index: Optional[MessageBoxIndex] = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
if ctx is None:
|
|
131
|
+
raise InvalidArgument("ctx is None")
|
|
132
|
+
if next_index is not None and owner is None:
|
|
133
|
+
raise InvalidArgument("owner is None")
|
|
134
|
+
self.write_cap: WriteCap = owner
|
|
135
|
+
self.ctx: bytes = bytes(ctx)
|
|
136
|
+
if next_index is None:
|
|
137
|
+
self.last_outbox_idx: Optional[MessageBoxIndex] = None
|
|
138
|
+
self.next_index: Optional[MessageBoxIndex] = owner.first_message_box_index if owner is not None else None
|
|
139
|
+
else:
|
|
140
|
+
self.last_outbox_idx = None
|
|
141
|
+
self.next_index = next_index
|
|
142
|
+
|
|
143
|
+
def next_box_id(self) -> bytes:
|
|
144
|
+
"""Returns the box ID this writer will write next, without advancing."""
|
|
145
|
+
if self.next_index is None:
|
|
146
|
+
raise InvalidArgument("next index is None")
|
|
147
|
+
if self.write_cap is None:
|
|
148
|
+
raise InvalidArgument("write_cap is None")
|
|
149
|
+
return self.next_index.box_id_for_context(self.write_cap.read_cap(), self.ctx)
|
|
150
|
+
|
|
151
|
+
def get_current_message_index(self) -> Optional[MessageBoxIndex]:
|
|
152
|
+
return self.next_index
|
|
153
|
+
|
|
154
|
+
def get_next_message_index(self) -> MessageBoxIndex:
|
|
155
|
+
"""Returns what next_index will be after advance_state. No mutation."""
|
|
156
|
+
if self.next_index is None:
|
|
157
|
+
raise InvalidArgument("next index is None")
|
|
158
|
+
return self.next_index.next_index()
|
|
159
|
+
|
|
160
|
+
def prepare_next(self, plaintext: bytes) -> Tuple[bytes, bytes, bytes]:
|
|
161
|
+
"""Encrypts a message WITHOUT advancing state.
|
|
162
|
+
|
|
163
|
+
Returns (box_id, ciphertext, signature). Call advance_state after
|
|
164
|
+
the courier acknowledges receipt.
|
|
165
|
+
"""
|
|
166
|
+
if self.next_index is None:
|
|
167
|
+
raise InvalidArgument("next index is None")
|
|
168
|
+
return self.next_index.encrypt_for_context(self.write_cap, self.ctx, plaintext)
|
|
169
|
+
|
|
170
|
+
def advance_state(self) -> None:
|
|
171
|
+
"""Advances the writer's state to the next index.
|
|
172
|
+
|
|
173
|
+
Should only be called after the courier acknowledges receipt.
|
|
174
|
+
"""
|
|
175
|
+
if self.next_index is None:
|
|
176
|
+
raise InvalidArgument("next index is None")
|
|
177
|
+
next_idx = self.next_index.next_index()
|
|
178
|
+
self.last_outbox_idx = self.next_index
|
|
179
|
+
self.next_index = next_idx
|
|
180
|
+
|
|
181
|
+
def encrypt_next(self, plaintext: bytes) -> Tuple[bytes, bytes, bytes]:
|
|
182
|
+
"""Encrypts a message and advances state after the encrypt succeeds.
|
|
183
|
+
|
|
184
|
+
Equivalent to prepare_next + advance_state; provided for the
|
|
185
|
+
common case where you do not need to defer the advance.
|
|
186
|
+
"""
|
|
187
|
+
result = self.prepare_next(plaintext)
|
|
188
|
+
self.advance_state()
|
|
189
|
+
return result
|
hpqc/bacap/stateless.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 David Stainton
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
"""Stateless BACAP types and operations.
|
|
4
|
+
|
|
5
|
+
Direct port of the stateless surface of bacap/bacap.go. The classes
|
|
6
|
+
here are immutable values; every cryptographic operation either
|
|
7
|
+
returns a new object or returns a tuple of bytes. Callers managing
|
|
8
|
+
sequential state across messages can either re-derive each step from
|
|
9
|
+
a known starting point on demand, or use the StatefulReader /
|
|
10
|
+
StatefulWriter wrappers in stateful.py, which simply hold a mutable
|
|
11
|
+
next-index pointer and advance it after each successful operation.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import os
|
|
19
|
+
import struct
|
|
20
|
+
from typing import Callable, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from cryptography.exceptions import InvalidTag
|
|
23
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
|
|
24
|
+
from nacl.exceptions import BadSignatureError
|
|
25
|
+
from nacl.signing import VerifyKey as NaclVerifyKey
|
|
26
|
+
|
|
27
|
+
from hpqc.sign.ed25519 import (
|
|
28
|
+
SigningKey as BlindableSigningKey,
|
|
29
|
+
VerifyKey as BlindableVerifyKey,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from .exceptions import (
|
|
33
|
+
CannotRewind,
|
|
34
|
+
DecryptionFailed,
|
|
35
|
+
InvalidArgument,
|
|
36
|
+
SignatureVerificationFailed,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Sizes mirror those declared in bacap/bacap.go.
|
|
41
|
+
MessageBoxIndexSize: int = 8 + 32 + 32 + 32 # 104
|
|
42
|
+
BoxIDSize: int = 32 # ed25519 public key
|
|
43
|
+
SignatureSize: int = 64 # ed25519 signature
|
|
44
|
+
_Ed25519PrivateKeySize: int = 64 # seed || pubkey, matching Go's encoding
|
|
45
|
+
WriteCapSize: int = _Ed25519PrivateKeySize + MessageBoxIndexSize # 168
|
|
46
|
+
ReadCapSize: int = BoxIDSize + MessageBoxIndexSize # 136
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _hkdf_blake2b(secret: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
|
50
|
+
"""HKDF-BLAKE2b-512 per RFC 5869.
|
|
51
|
+
|
|
52
|
+
Parameter ordering matches Go's hkdf.New(blake2b.New512(nil), secret,
|
|
53
|
+
salt, info). An empty salt is replaced with a hash-length zero block,
|
|
54
|
+
per the RFC.
|
|
55
|
+
"""
|
|
56
|
+
digest_size = 64
|
|
57
|
+
if not salt:
|
|
58
|
+
salt = b"\x00" * digest_size
|
|
59
|
+
prk = hmac.new(salt, secret, hashlib.blake2b).digest()
|
|
60
|
+
out = bytearray()
|
|
61
|
+
t = b""
|
|
62
|
+
counter = 1
|
|
63
|
+
while len(out) < length:
|
|
64
|
+
t = hmac.new(prk, t + info + bytes([counter]), hashlib.blake2b).digest()
|
|
65
|
+
out += t
|
|
66
|
+
counter += 1
|
|
67
|
+
return bytes(out[:length])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclasses.dataclass(frozen=True)
|
|
71
|
+
class MessageBoxIndex:
|
|
72
|
+
"""Position in the BACAP message-box derivation sequence.
|
|
73
|
+
|
|
74
|
+
Wire format (104 bytes, little-endian):
|
|
75
|
+
idx_64 : 8 bytes
|
|
76
|
+
cur_blinding_factor : 32 bytes
|
|
77
|
+
cur_encryption_key : 32 bytes
|
|
78
|
+
hkdf_state : 32 bytes
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
idx_64: int
|
|
82
|
+
cur_blinding_factor: bytes # 32 bytes
|
|
83
|
+
cur_encryption_key: bytes # 32 bytes
|
|
84
|
+
hkdf_state: bytes # 32 bytes
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
if not (0 <= self.idx_64 < 1 << 64):
|
|
88
|
+
raise InvalidArgument("idx_64 out of range for uint64")
|
|
89
|
+
for name in ("cur_blinding_factor", "cur_encryption_key", "hkdf_state"):
|
|
90
|
+
v = getattr(self, name)
|
|
91
|
+
if not isinstance(v, (bytes, bytearray)) or len(v) != 32:
|
|
92
|
+
raise InvalidArgument(f"{name} must be 32 bytes")
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def empty(cls) -> "MessageBoxIndex":
|
|
96
|
+
zero = b"\x00" * 32
|
|
97
|
+
return cls(0, zero, zero, zero)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def random(cls, rng: Optional[Callable[[int], bytes]] = None) -> "MessageBoxIndex":
|
|
101
|
+
"""Creates a freshly-randomized MessageBoxIndex.
|
|
102
|
+
|
|
103
|
+
Picks a random HKDF state and a random starting Idx64 in
|
|
104
|
+
roughly 0..2^63-2 (using the Irwin-Hall sum of two 2^62-bit
|
|
105
|
+
integers strategy from the Go implementation), then advances
|
|
106
|
+
once so that the returned index has properly-derived blinding
|
|
107
|
+
and encryption keys.
|
|
108
|
+
"""
|
|
109
|
+
if rng is None:
|
|
110
|
+
rng = os.urandom
|
|
111
|
+
hkdf_state = rng(32)
|
|
112
|
+
idx_bytes = bytearray(rng(16))
|
|
113
|
+
idx_bytes[0] &= 0x2F
|
|
114
|
+
idx_bytes[8] &= 0x2F
|
|
115
|
+
a = int.from_bytes(idx_bytes[:8], "little")
|
|
116
|
+
b = int.from_bytes(idx_bytes[8:], "little")
|
|
117
|
+
# Go's uint64 addition wraps on overflow; mirror that.
|
|
118
|
+
idx = (a + b) & 0xFFFFFFFFFFFFFFFF
|
|
119
|
+
seed = cls(idx, b"\x00" * 32, b"\x00" * 32, hkdf_state)
|
|
120
|
+
return seed.next_index()
|
|
121
|
+
|
|
122
|
+
def to_bytes(self) -> bytes:
|
|
123
|
+
return (
|
|
124
|
+
struct.pack("<Q", self.idx_64)
|
|
125
|
+
+ self.cur_blinding_factor
|
|
126
|
+
+ self.cur_encryption_key
|
|
127
|
+
+ self.hkdf_state
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_bytes(cls, data: bytes) -> "MessageBoxIndex":
|
|
132
|
+
if len(data) != MessageBoxIndexSize:
|
|
133
|
+
raise InvalidArgument("invalid MessageBoxIndex binary size")
|
|
134
|
+
return cls(
|
|
135
|
+
struct.unpack("<Q", data[:8])[0],
|
|
136
|
+
bytes(data[8:40]),
|
|
137
|
+
bytes(data[40:72]),
|
|
138
|
+
bytes(data[72:104]),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# ----- index advancement -----
|
|
142
|
+
|
|
143
|
+
def advance_index_to(self, target: int) -> "MessageBoxIndex":
|
|
144
|
+
"""Returns a new MessageBoxIndex stepped to the given idx via HKDF."""
|
|
145
|
+
if target < self.idx_64:
|
|
146
|
+
raise CannotRewind(
|
|
147
|
+
f"cannot rewind index: target {target} < current {self.idx_64}"
|
|
148
|
+
)
|
|
149
|
+
if target == self.idx_64:
|
|
150
|
+
return self
|
|
151
|
+
cur_idx = self.idx_64
|
|
152
|
+
hkdf_state = self.hkdf_state
|
|
153
|
+
cur_enc_key = b"\x00" * 32
|
|
154
|
+
cur_blinding = b"\x00" * 32
|
|
155
|
+
while cur_idx < target:
|
|
156
|
+
okm = _hkdf_blake2b(
|
|
157
|
+
secret=hkdf_state,
|
|
158
|
+
salt=b"",
|
|
159
|
+
info=struct.pack("<Q", cur_idx),
|
|
160
|
+
length=96,
|
|
161
|
+
)
|
|
162
|
+
hkdf_state = okm[:32]
|
|
163
|
+
cur_enc_key = okm[32:64]
|
|
164
|
+
cur_blinding = okm[64:96]
|
|
165
|
+
cur_idx += 1
|
|
166
|
+
return MessageBoxIndex(cur_idx, cur_blinding, cur_enc_key, hkdf_state)
|
|
167
|
+
|
|
168
|
+
def next_index(self) -> "MessageBoxIndex":
|
|
169
|
+
return self.advance_index_to(self.idx_64 + 1)
|
|
170
|
+
|
|
171
|
+
# ----- box-ID derivation -----
|
|
172
|
+
|
|
173
|
+
def derive_message_box_id(self, root_public_key: BlindableVerifyKey) -> bytes:
|
|
174
|
+
"""Returns the blinded box-ID public key (32 bytes)."""
|
|
175
|
+
return bytes(root_public_key.blind(self.cur_blinding_factor))
|
|
176
|
+
|
|
177
|
+
def _derive_k_for_context(self, ctx: bytes) -> bytes:
|
|
178
|
+
return _hkdf_blake2b(
|
|
179
|
+
secret=self.cur_blinding_factor,
|
|
180
|
+
salt=ctx,
|
|
181
|
+
info=b"",
|
|
182
|
+
length=32,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _derive_e_for_context(self, ctx: bytes) -> bytes:
|
|
186
|
+
return _hkdf_blake2b(
|
|
187
|
+
secret=self.cur_encryption_key,
|
|
188
|
+
salt=ctx,
|
|
189
|
+
info=b"",
|
|
190
|
+
length=32,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def box_id_for_context(self, read_cap: "ReadCap", ctx: bytes) -> bytes:
|
|
194
|
+
"""Returns the box ID (32-byte blinded ed25519 pubkey) for ctx."""
|
|
195
|
+
k_ctx = self._derive_k_for_context(ctx)
|
|
196
|
+
return bytes(read_cap.root_public_key.blind(k_ctx))
|
|
197
|
+
|
|
198
|
+
# ----- sign / verify -----
|
|
199
|
+
|
|
200
|
+
def sign_box(
|
|
201
|
+
self, owner: "WriteCap", ctx: bytes, ciphertext: bytes
|
|
202
|
+
) -> Tuple[bytes, bytes]:
|
|
203
|
+
"""Signs ciphertext under the blinded private key for this index+ctx.
|
|
204
|
+
|
|
205
|
+
Returns (box_id, signature). The signature verifies under standard
|
|
206
|
+
Ed25519 against box_id, so any standards-compliant verifier works.
|
|
207
|
+
"""
|
|
208
|
+
k_ctx = self._derive_k_for_context(ctx)
|
|
209
|
+
box_id = bytes(owner.root_public_key.blind(k_ctx))
|
|
210
|
+
blinded_priv = owner.root_private_key.blind(k_ctx)
|
|
211
|
+
sig = blinded_priv.sign(ciphertext)[:64]
|
|
212
|
+
return box_id, sig
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def verify_box(box: bytes, ciphertext: bytes, signature: bytes) -> bool:
|
|
216
|
+
"""Returns True iff signature verifies under box (the box-ID pubkey)."""
|
|
217
|
+
if len(box) != BoxIDSize:
|
|
218
|
+
raise InvalidArgument("invalid box length")
|
|
219
|
+
try:
|
|
220
|
+
NaclVerifyKey(box).verify(ciphertext, signature)
|
|
221
|
+
except BadSignatureError:
|
|
222
|
+
return False
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
# ----- encrypt / decrypt -----
|
|
226
|
+
|
|
227
|
+
def encrypt_for_context(
|
|
228
|
+
self, owner: "WriteCap", ctx: bytes, plaintext: bytes
|
|
229
|
+
) -> Tuple[bytes, bytes, bytes]:
|
|
230
|
+
"""Encrypts plaintext for this index+ctx.
|
|
231
|
+
|
|
232
|
+
Returns (box_id, ciphertext, signature). The signature is over the
|
|
233
|
+
ciphertext, made by the blinded private key whose corresponding
|
|
234
|
+
blinded pubkey is box_id.
|
|
235
|
+
"""
|
|
236
|
+
k_ctx = self._derive_k_for_context(ctx)
|
|
237
|
+
box_id = bytes(owner.root_public_key.blind(k_ctx))
|
|
238
|
+
e_ctx = self._derive_e_for_context(ctx)
|
|
239
|
+
nonce = box_id[:12]
|
|
240
|
+
aad = box_id
|
|
241
|
+
ciphertext = AESGCMSIV(e_ctx).encrypt(nonce, plaintext, aad)
|
|
242
|
+
blinded_priv = owner.root_private_key.blind(k_ctx)
|
|
243
|
+
sig = blinded_priv.sign(ciphertext)[:64]
|
|
244
|
+
return box_id, ciphertext, sig
|
|
245
|
+
|
|
246
|
+
def decrypt_for_context(
|
|
247
|
+
self,
|
|
248
|
+
box: bytes,
|
|
249
|
+
ctx: bytes,
|
|
250
|
+
ciphertext: bytes,
|
|
251
|
+
signature: bytes,
|
|
252
|
+
) -> bytes:
|
|
253
|
+
"""Verifies signature, then AES-GCM-SIV-decrypts ciphertext.
|
|
254
|
+
|
|
255
|
+
An empty ciphertext is treated as a tombstone: the signature is
|
|
256
|
+
verified over the empty payload and an empty plaintext is returned
|
|
257
|
+
without any decryption.
|
|
258
|
+
"""
|
|
259
|
+
if len(box) != BoxIDSize:
|
|
260
|
+
raise InvalidArgument("invalid box length")
|
|
261
|
+
try:
|
|
262
|
+
NaclVerifyKey(box).verify(ciphertext, signature)
|
|
263
|
+
except BadSignatureError as e:
|
|
264
|
+
raise SignatureVerificationFailed(
|
|
265
|
+
"signature did not verify under the box-ID public key"
|
|
266
|
+
) from e
|
|
267
|
+
if not ciphertext:
|
|
268
|
+
return b""
|
|
269
|
+
e_ctx = self._derive_e_for_context(ctx)
|
|
270
|
+
try:
|
|
271
|
+
return AESGCMSIV(e_ctx).decrypt(box[:12], ciphertext, box)
|
|
272
|
+
except InvalidTag as e:
|
|
273
|
+
raise DecryptionFailed("AES-256-GCM-SIV authentication failed") from e
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _seed_from_signing_key(sk: BlindableSigningKey) -> bytes:
|
|
277
|
+
"""Returns the 32-byte ed25519 seed from a SigningKey."""
|
|
278
|
+
return bytes(sk)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _ed25519_64byte_private(sk: BlindableSigningKey) -> bytes:
|
|
282
|
+
"""Returns the 64-byte (seed||pubkey) form of an ed25519 private key.
|
|
283
|
+
|
|
284
|
+
Matches Go's crypto/ed25519 marshaling so that WriteCap.to_bytes()
|
|
285
|
+
is byte-identical to the Go side's WriteCap.MarshalBinary().
|
|
286
|
+
"""
|
|
287
|
+
return _seed_from_signing_key(sk) + bytes(sk.verify_key)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclasses.dataclass(frozen=True)
|
|
291
|
+
class WriteCap:
|
|
292
|
+
"""Holds the root private key plus the conversation's first MessageBoxIndex.
|
|
293
|
+
|
|
294
|
+
The bearer of a WriteCap can derive the full box ID sequence and the
|
|
295
|
+
corresponding signing keys; deriving a ReadCap from it gives someone
|
|
296
|
+
else the ability to read but not write.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
root_private_key: BlindableSigningKey
|
|
300
|
+
first_message_box_index: MessageBoxIndex
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def root_public_key(self) -> BlindableVerifyKey:
|
|
304
|
+
return self.root_private_key.verify_key
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def generate(
|
|
308
|
+
cls,
|
|
309
|
+
rng: Optional[Callable[[int], bytes]] = None,
|
|
310
|
+
) -> "WriteCap":
|
|
311
|
+
"""Returns a new WriteCap with a fresh random keypair and first index."""
|
|
312
|
+
if rng is None:
|
|
313
|
+
rng = os.urandom
|
|
314
|
+
sk = BlindableSigningKey(rng(32))
|
|
315
|
+
return cls(sk, MessageBoxIndex.random(rng))
|
|
316
|
+
|
|
317
|
+
def to_bytes(self) -> bytes:
|
|
318
|
+
return _ed25519_64byte_private(self.root_private_key) + self.first_message_box_index.to_bytes()
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def from_bytes(cls, data: bytes) -> "WriteCap":
|
|
322
|
+
if len(data) != WriteCapSize:
|
|
323
|
+
raise InvalidArgument("invalid WriteCap binary size")
|
|
324
|
+
# Go stores seed||pubkey; we re-derive pubkey from seed and ignore
|
|
325
|
+
# the stored pubkey (it is implied by the seed).
|
|
326
|
+
sk = BlindableSigningKey(data[:32])
|
|
327
|
+
idx = MessageBoxIndex.from_bytes(data[_Ed25519PrivateKeySize:])
|
|
328
|
+
return cls(sk, idx)
|
|
329
|
+
|
|
330
|
+
def read_cap(self) -> "ReadCap":
|
|
331
|
+
"""Returns the ReadCap derived from this WriteCap."""
|
|
332
|
+
return ReadCap(self.root_public_key, self.first_message_box_index)
|
|
333
|
+
|
|
334
|
+
def derive_box_id(self, message_box_index: MessageBoxIndex) -> bytes:
|
|
335
|
+
return message_box_index.derive_message_box_id(self.root_public_key)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclasses.dataclass(frozen=True)
|
|
339
|
+
class ReadCap:
|
|
340
|
+
"""Holds the root public key plus the conversation's first MessageBoxIndex.
|
|
341
|
+
|
|
342
|
+
The bearer can derive the full box-ID sequence and verify+decrypt
|
|
343
|
+
messages, but cannot sign new ones.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
root_public_key: BlindableVerifyKey
|
|
347
|
+
first_message_box_index: MessageBoxIndex
|
|
348
|
+
|
|
349
|
+
def to_bytes(self) -> bytes:
|
|
350
|
+
return bytes(self.root_public_key) + self.first_message_box_index.to_bytes()
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def from_bytes(cls, data: bytes) -> "ReadCap":
|
|
354
|
+
if len(data) != ReadCapSize:
|
|
355
|
+
raise InvalidArgument("invalid ReadCap binary size")
|
|
356
|
+
pk = BlindableVerifyKey(data[:BoxIDSize])
|
|
357
|
+
idx = MessageBoxIndex.from_bytes(data[BoxIDSize:])
|
|
358
|
+
return cls(pk, idx)
|
|
359
|
+
|
|
360
|
+
def derive_box_id(self, message_box_index: MessageBoxIndex) -> bytes:
|
|
361
|
+
return message_box_index.derive_message_box_id(self.root_public_key)
|