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 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
+ ]
@@ -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
@@ -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)