transcrypto 1.1.2__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
transcrypto/aes.py CHANGED
@@ -47,7 +47,7 @@ assert _PASSWORD_ITERATIONS == (6075308 + 1) // 3, 'should never happen: constan
47
47
 
48
48
 
49
49
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
50
- class AESKey(base.CryptoKey, base.SymmetricCrypto):
50
+ class AESKey(base.CryptoKey, base.Encryptor, base.Decryptor):
51
51
  """Advanced Encryption Standard (AES) 256 bits key (32 bytes).
52
52
 
53
53
  No measures are taken here to prevent timing attacks.
@@ -112,7 +112,7 @@ class AESKey(base.CryptoKey, base.SymmetricCrypto):
112
112
  salt=_PASSWORD_SALT_256, iterations=_PASSWORD_ITERATIONS)
113
113
  return cls(key256=kdf.derive(str_password.encode('utf-8')))
114
114
 
115
- class ECBEncoderClass(base.SymmetricCrypto):
115
+ class ECBEncoderClass(base.Encryptor, base.Decryptor):
116
116
  """The simplest encryption possible (UNSAFE if misused): 128 bit block AES-ECB, 256 bit key.
117
117
 
118
118
  Please DO **NOT** use this for regular cryptography. For regular crypto use Encrypt()/Decrypt().
@@ -245,7 +245,8 @@ class AESKey(base.CryptoKey, base.SymmetricCrypto):
245
245
  InputError: invalid inputs
246
246
  CryptoError: internal crypto failures, authentication failure, key mismatch, etc
247
247
  """
248
- assert len(ciphertext) >= 32, 'should never happen: AES256+GCM should have ≥32 bytes IV/CT/tag'
248
+ if len(ciphertext) < 32:
249
+ raise base.InputError(f'AES256+GCM should have ≥32 bytes IV/CT/tag: {len(ciphertext)}')
249
250
  iv, tag = ciphertext[:16], ciphertext[-16:]
250
251
  decryptor: ciphers.CipherContext = ciphers.Cipher(
251
252
  algorithms.AES256(self.key256), modes.GCM(iv, tag)).decryptor()
transcrypto/base.py CHANGED
@@ -19,12 +19,11 @@ import pickle
19
19
  # import pdb
20
20
  import secrets
21
21
  import time
22
- from typing import Any, Callable, final, MutableSequence, Self, TypeVar
23
-
22
+ from typing import Any, Callable, final, MutableSequence, Protocol, runtime_checkable, Self, TypeVar
24
23
  import zstandard
25
24
 
26
25
  __author__ = 'balparda@github.com'
27
- __version__ = '1.1.2' # v1.1.2, 2025-08-29
26
+ __version__ = '1.2.0' # 2025-09-05, Fri
28
27
  __version_tuple__: tuple[int, ...] = tuple(int(v) for v in __version__.split('.'))
29
28
 
30
29
  # MIN_TM = int( # minimum allowed timestamp
@@ -35,8 +34,8 @@ BytesToInt: Callable[[bytes], int] = lambda b: int.from_bytes(b, 'big', signed=F
35
34
  BytesToEncoded: Callable[[bytes], str] = lambda b: base64.urlsafe_b64encode(b).decode('ascii')
36
35
 
37
36
  HexToBytes: Callable[[str], bytes] = bytes.fromhex
38
- IntToBytes: Callable[[int], bytes] = lambda i: i.to_bytes(
39
- (i.bit_length() + 7) // 8, 'big', signed=False)
37
+ IntToFixedBytes: Callable[[int, int], bytes] = lambda i, n: i.to_bytes(n, 'big', signed=False)
38
+ IntToBytes: Callable[[int], bytes] = lambda i: IntToFixedBytes(i, (i.bit_length() + 7) // 8)
40
39
  IntToEncoded: Callable[[int], str] = lambda i: BytesToEncoded(IntToBytes(i))
41
40
  EncodedToBytes: Callable[[str], bytes] = lambda e: base64.urlsafe_b64decode(e.encode('ascii'))
42
41
 
@@ -46,7 +45,7 @@ PadBytesTo: Callable[[bytes, int], bytes] = lambda b, i: b.rjust((i + 7) // 8, b
46
45
  # these control the pickling of data, do NOT ever change, or you will break all databases
47
46
  # <https://docs.python.org/3/library/pickle.html#pickle.DEFAULT_PROTOCOL>
48
47
  _PICKLE_PROTOCOL = 4 # protocol 4 available since python v3.8 # do NOT ever change!
49
- _PICKLE_AAD = b'transcrypto.base.Serialize' # do NOT ever change!
48
+ _PICKLE_AAD = b'transcrypto.base.Serialize.1.0' # do NOT ever change!
50
49
  # these help find compressed files, do NOT change unless zstandard changes
51
50
  _ZSTD_MAGIC_FRAME = 0xFD2FB528
52
51
  _ZSTD_MAGIC_SKIPPABLE_MIN = 0x184D2A50
@@ -646,11 +645,11 @@ class CryptoKey(abc.ABC):
646
645
  return BytesToEncoded(self.blob)
647
646
 
648
647
  @final
649
- def Blob(self, /, *, key: SymmetricCrypto | None = None, silent: bool = True) -> bytes:
648
+ def Blob(self, /, *, key: Encryptor | None = None, silent: bool = True) -> bytes:
650
649
  """Serial (bytes) representation of the object with more options, including encryption.
651
650
 
652
651
  Args:
653
- key (SymmetricCrypto, optional): if given will key.Encrypt() data before saving
652
+ key (Encryptor, optional): if given will key.Encrypt() data before saving
654
653
  silent (bool, optional): if True (default) will not log
655
654
 
656
655
  Returns:
@@ -659,11 +658,11 @@ class CryptoKey(abc.ABC):
659
658
  return Serialize(self, compress=-2, key=key, silent=silent)
660
659
 
661
660
  @final
662
- def Encoded(self, /, *, key: SymmetricCrypto | None = None, silent: bool = True) -> str:
661
+ def Encoded(self, /, *, key: Encryptor | None = None, silent: bool = True) -> str:
663
662
  """Base-64 representation of the object with more options, including encryption.
664
663
 
665
664
  Args:
666
- key (SymmetricCrypto, optional): if given will key.Encrypt() data before saving
665
+ key (Encryptor, optional): if given will key.Encrypt() data before saving
667
666
  silent (bool, optional): if True (default) will not log
668
667
 
669
668
  Returns:
@@ -674,14 +673,13 @@ class CryptoKey(abc.ABC):
674
673
  @final
675
674
  @classmethod
676
675
  def Load(
677
- cls, data: str | bytes, /, *,
678
- key: SymmetricCrypto | None = None, silent: bool = True) -> Self:
676
+ cls, data: str | bytes, /, *, key: Decryptor | None = None, silent: bool = True) -> Self:
679
677
  """Load (create) object from serialized bytes or string.
680
678
 
681
679
  Args:
682
680
  data (str | bytes): if bytes is assumed from CryptoKey.blob/Blob(), and
683
681
  if string is assumed from CryptoKey.encoded/Encoded()
684
- key (SymmetricCrypto, optional): if given will key.Encrypt() data before saving
682
+ key (Decryptor, optional): if given will key.Encrypt() data before saving
685
683
  silent (bool, optional): if True (default) will not log
686
684
 
687
685
  Returns:
@@ -698,20 +696,21 @@ class CryptoKey(abc.ABC):
698
696
  return obj # type:ignore
699
697
 
700
698
 
701
- class SymmetricCrypto(abc.ABC):
702
- """Abstract interface for symmetric encryption.
699
+ @runtime_checkable
700
+ class Encryptor(Protocol): # pylint: disable=too-few-public-methods
701
+ """Abstract interface for a class that has encryption
703
702
 
704
703
  Contract:
705
704
  - If algorithm accepts a `nonce` or `tag` these have to be handled internally by the
706
- implementation and appended to the ciphertext.
705
+ implementation and appended to the `ciphertext`/`signature`.
707
706
  - If AEAD is supported, `associated_data` (AAD) must be authenticated. If not supported
708
707
  then `associated_data` different from None must raise InputError.
709
708
 
710
709
  Notes:
711
710
  The interface is deliberately minimal: byte-in / byte-out.
712
711
  Metadata like nonce/tag may be:
713
- - returned alongside ciphertext, or
714
- - bundled/serialized into `ciphertext` by the implementation.
712
+ - returned alongside `ciphertext`/`signature`, or
713
+ - bundled/serialized into `ciphertext`/`signature` by the implementation.
715
714
  """
716
715
 
717
716
  @abc.abstractmethod
@@ -732,6 +731,11 @@ class SymmetricCrypto(abc.ABC):
732
731
  CryptoError: internal crypto failures
733
732
  """
734
733
 
734
+
735
+ @runtime_checkable
736
+ class Decryptor(Protocol): # pylint: disable=too-few-public-methods
737
+ """Abstract interface for a class that has decryption (see contract/notes in Encryptor)."""
738
+
735
739
  @abc.abstractmethod
736
740
  def Decrypt(self, ciphertext: bytes, /, *, associated_data: bytes | None = None) -> bytes:
737
741
  """Decrypt `ciphertext` and return the original `plaintext`.
@@ -749,9 +753,55 @@ class SymmetricCrypto(abc.ABC):
749
753
  """
750
754
 
751
755
 
756
+ @runtime_checkable
757
+ class Verifier(Protocol): # pylint: disable=too-few-public-methods
758
+ """Abstract interface for asymmetric signature verify. (see contract/notes in Encryptor)."""
759
+
760
+ @abc.abstractmethod
761
+ def Verify(
762
+ self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None) -> bool:
763
+ """Verify a `signature` for `message`. True if OK; False if failed verification.
764
+
765
+ Args:
766
+ message (bytes): Data that was signed (including any embedded nonce/tag if applicable)
767
+ signature (bytes): Signature data to verify (including any embedded nonce/tag if applicable)
768
+ associated_data (bytes, optional): Optional AAD (must match what was used during signing)
769
+
770
+ Returns:
771
+ True if signature is valid, False otherwise
772
+
773
+ Raises:
774
+ InputError: invalid inputs
775
+ CryptoError: internal crypto failures, authentication failure, key mismatch, etc
776
+ """
777
+
778
+
779
+ @runtime_checkable
780
+ class Signer(Protocol): # pylint: disable=too-few-public-methods
781
+ """Abstract interface for asymmetric signing. (see contract/notes in Encryptor)."""
782
+
783
+ @abc.abstractmethod
784
+ def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
785
+ """Sign `message` and return the `signature`.
786
+
787
+ Args:
788
+ message (bytes): Data to sign.
789
+ associated_data (bytes, optional): Optional AAD for AEAD modes; must be
790
+ provided again on decrypt
791
+
792
+ Returns:
793
+ bytes: Signature; if a nonce/tag is needed for decryption, the implementation
794
+ must encode it within the returned bytes (or document how to retrieve it)
795
+
796
+ Raises:
797
+ InputError: invalid inputs
798
+ CryptoError: internal crypto failures
799
+ """
800
+
801
+
752
802
  def Serialize(
753
803
  python_obj: Any, /, *, file_path: str | None = None,
754
- compress: int | None = 3, key: SymmetricCrypto | None = None, silent: bool = False) -> bytes:
804
+ compress: int | None = 3, key: Encryptor | None = None, silent: bool = False) -> bytes:
755
805
  """Serialize a Python object into a BLOB, optionally compress / encrypt / save to disk.
756
806
 
757
807
  Data path is:
@@ -777,7 +827,7 @@ def Serialize(
777
827
  file_path (str, optional): full path to optionally save the data to
778
828
  compress (int | None, optional): Compress level before encrypting/saving; -22 ≤ compress ≤ 22;
779
829
  None is no compression; default is 3, which is fast, see table above for other values
780
- key (SymmetricCrypto, optional): if given will key.Encrypt() data before saving
830
+ key (Encryptor, optional): if given will key.Encrypt() data before saving
781
831
  silent (bool, optional): if True will not log; default is False (will log)
782
832
 
783
833
  Returns:
@@ -819,7 +869,7 @@ def Serialize(
819
869
 
820
870
  def DeSerialize(
821
871
  *, data: bytes | None = None, file_path: str | None = None,
822
- key: SymmetricCrypto | None = None, silent: bool = False) -> Any:
872
+ key: Decryptor | None = None, silent: bool = False) -> Any:
823
873
  """Loads (de-serializes) a BLOB back to a Python object, optionally decrypting / decompressing.
824
874
 
825
875
  Data path is:
@@ -835,7 +885,7 @@ def DeSerialize(
835
885
  if you use this option, `file_path` will be ignored
836
886
  file_path (str, optional): if given, use this as file path to load binary data string (input);
837
887
  if you use this option, `data` will be ignored
838
- key (SymmetricCrypto, optional): if given will key.Decrypt() data before decompressing/loading
888
+ key (Decryptor, optional): if given will key.Decrypt() data before decompressing/loading
839
889
  silent (bool, optional): if True will not log; default is False (will log)
840
890
 
841
891
  Returns:
@@ -893,7 +943,7 @@ def DeSerialize(
893
943
 
894
944
 
895
945
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
896
- class PublicBid(CryptoKey):
946
+ class PublicBid512(CryptoKey):
897
947
  """Public commitment to a (cryptographically secure) bid that can be revealed/validated later.
898
948
 
899
949
  Bid is computed as: public_hash = Hash512(public_key || private_key || secret_bid)
@@ -901,6 +951,8 @@ class PublicBid(CryptoKey):
901
951
  Everything is bytes. The public part is (public_key, public_hash) and the private
902
952
  part is (private_key, secret_bid). The whole computation can be checked later.
903
953
 
954
+ No measures are taken here to prevent timing attacks (probably not a concern).
955
+
904
956
  Attributes:
905
957
  public_key (bytes): 512-bits random value
906
958
  public_hash (bytes): SHA-512 hash of (public_key || private_key || secret_bid)
@@ -915,7 +967,7 @@ class PublicBid(CryptoKey):
915
967
  Raises:
916
968
  InputError: invalid inputs
917
969
  """
918
- super(PublicBid, self).__post_init__() # pylint: disable=super-with-arguments # needed here b/c: dataclass
970
+ super(PublicBid512, self).__post_init__() # pylint: disable=super-with-arguments # needed here b/c: dataclass
919
971
  if len(self.public_key) != 64 or len(self.public_hash) != 64:
920
972
  raise InputError(f'invalid public_key or public_hash: {self}')
921
973
 
@@ -925,7 +977,8 @@ class PublicBid(CryptoKey):
925
977
  Returns:
926
978
  string representation of PublicBid
927
979
  """
928
- return (f'PublicBid(public_key={BytesToEncoded(self.public_key)}, '
980
+ return ('PublicBid512('
981
+ f'public_key={BytesToEncoded(self.public_key)}, '
929
982
  f'public_hash={BytesToHex(self.public_hash)})')
930
983
 
931
984
  def VerifyBid(self, private_key: bytes, secret: bytes, /) -> bool:
@@ -943,7 +996,7 @@ class PublicBid(CryptoKey):
943
996
  """
944
997
  try:
945
998
  # creating the PrivateBid object will validate everything; InputError we allow to propagate
946
- PrivateBid(
999
+ PrivateBid512(
947
1000
  public_key=self.public_key, public_hash=self.public_hash,
948
1001
  private_key=private_key, secret_bid=secret)
949
1002
  return True # if we got here, all is good
@@ -951,13 +1004,13 @@ class PublicBid(CryptoKey):
951
1004
  return False # bid does not match the public commitment
952
1005
 
953
1006
  @classmethod
954
- def Copy(cls, other: PublicBid, /) -> Self:
1007
+ def Copy(cls, other: PublicBid512, /) -> Self:
955
1008
  """Initialize a public bid by taking the public parts of a public/private bid."""
956
1009
  return cls(public_key=other.public_key, public_hash=other.public_hash)
957
1010
 
958
1011
 
959
1012
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
960
- class PrivateBid(PublicBid):
1013
+ class PrivateBid512(PublicBid512):
961
1014
  """Private bid that can be revealed and validated against a public commitment (see PublicBid).
962
1015
 
963
1016
  Attributes:
@@ -975,7 +1028,7 @@ class PrivateBid(PublicBid):
975
1028
  InputError: invalid inputs
976
1029
  CryptoError: bid does not match the public commitment
977
1030
  """
978
- super(PrivateBid, self).__post_init__() # pylint: disable=super-with-arguments # needed here b/c: dataclass
1031
+ super(PrivateBid512, self).__post_init__() # pylint: disable=super-with-arguments # needed here b/c: dataclass
979
1032
  if len(self.private_key) != 64 or len(self.secret_bid) < 1:
980
1033
  raise InputError(f'invalid private_key or secret_bid: {self}')
981
1034
  if self.public_hash != Hash512(self.public_key + self.private_key + self.secret_bid):
@@ -987,7 +1040,8 @@ class PrivateBid(PublicBid):
987
1040
  Returns:
988
1041
  string representation of PrivateBid without leaking secrets
989
1042
  """
990
- return (f'PrivateBid({super(PrivateBid, self).__str__()}, ' # pylint: disable=super-with-arguments
1043
+ return ('PrivateBid512('
1044
+ f'{super(PrivateBid512, self).__str__()}, ' # pylint: disable=super-with-arguments
991
1045
  f'private_key={ObfuscateSecret(self.private_key)}, '
992
1046
  f'secret_bid={ObfuscateSecret(self.secret_bid)})')
993
1047
 
transcrypto/dsa.py CHANGED
@@ -17,21 +17,30 @@ import logging
17
17
  # import pdb
18
18
  from typing import Self
19
19
 
20
- from . import base
21
- from . import modmath
20
+ from . import base, modmath
22
21
 
23
22
  __author__ = 'balparda@github.com'
24
23
  __version__: str = base.__version__ # version comes from base!
25
24
  __version_tuple__: tuple[int, ...] = base.__version_tuple__
26
25
 
27
26
 
28
- _PRIME_MULTIPLE_SEARCH = 30
27
+ _PRIME_MULTIPLE_SEARCH = 4096 # how many multiples of q to try before restarting
29
28
  _MAX_KEY_GENERATION_FAILURES = 15
30
29
 
30
+ # fixed prefixes: do NOT ever change! will break all encryption and signature schemes
31
+ _DSA_SIGNATURE_HASH_PREFIX = b'transcrypto.DSA.Signature.1.0\x00'
32
+
31
33
 
32
34
  def NBitRandomDSAPrimes(p_bits: int, q_bits: int, /) -> tuple[int, int, int]:
33
35
  """Generates 2 random DSA primes p & q with `x_bits` size and (p-1)%q==0.
34
36
 
37
+ Uses an aggressive small-prime wheel sieve:
38
+ Before any Miller-Rabin we reject p = m·q + 1 if it is divisible by a small prime.
39
+ We precompute forbidden residues for m:
40
+ • For each small prime r (all primes up to, say, 100 000), we compute
41
+ m_forbidden ≡ -q⁻¹ (mod r) (because (m·q + 1) % r == 0 ⇔ m ≡ -q⁻¹ (mod r))
42
+ • When we iterate m, we skip values that hit any forbidden residue class.
43
+
35
44
  Args:
36
45
  p_bits (int): Number of guaranteed bits in `p` prime representation,
37
46
  p_bits ≥ q_bits + 11
@@ -50,23 +59,45 @@ def NBitRandomDSAPrimes(p_bits: int, q_bits: int, /) -> tuple[int, int, int]:
50
59
  if p_bits < q_bits + 11:
51
60
  raise base.InputError(f'invalid p_bits length: {p_bits=}')
52
61
  # make q
53
- q = modmath.NBitRandomPrime(q_bits)
62
+ q: int = modmath.NBitRandomPrime(q_bits)
63
+ # make list of small primes to use for sieving
64
+ approx_q_root: int = 1 << (q_bits // 2)
65
+ forbidden: dict[int, int] = {} # (modulus: forbidden residue)
66
+ for r in modmath.PrimeGenerator(3):
67
+ forbidden[r] = (-modmath.ModInv(q % r, r)) % r
68
+ if r > 100000 or r > approx_q_root:
69
+ break
70
+
71
+ def _PassesSieve(m: int) -> bool:
72
+ for r, f in forbidden.items():
73
+ if m % r == f:
74
+ return False
75
+ return True
76
+
54
77
  # find range of multiples to use
55
78
  min_p, max_p = 2 ** (p_bits - 1), 2 ** p_bits - 1
56
79
  min_m, max_m = min_p // q + 2, max_p // q - 2
57
80
  assert max_m - min_m > 1000 # make sure we'll have options!
58
81
  # start searching from a random multiple
59
82
  failures: int = 0
83
+ window: int = max(_PRIME_MULTIPLE_SEARCH, 2 * p_bits)
60
84
  while True:
61
85
  # try searching starting here
62
86
  m: int = base.RandInt(min_m, max_m)
63
- for _ in range(_PRIME_MULTIPLE_SEARCH):
87
+ if m % 2:
88
+ m += 1 # make even
89
+ for _ in range(window):
64
90
  p: int = q * m + 1
65
91
  if p >= max_p:
66
92
  break
93
+ # first do a quick sieve test
94
+ if not _PassesSieve(m):
95
+ m += 2
96
+ continue
97
+ # passed sieve, do full test
67
98
  if modmath.IsPrime(p):
68
99
  return (p, q, m) # found a suitable prime set!
69
- m += 1 # next multiple
100
+ m += 2 # next multiple
70
101
  # after _PRIME_MULTIPLE_SEARCH we declare this range failed
71
102
  failures += 1
72
103
  if failures >= _MAX_KEY_GENERATION_FAILURES:
@@ -78,8 +109,6 @@ def NBitRandomDSAPrimes(p_bits: int, q_bits: int, /) -> tuple[int, int, int]:
78
109
  class DSASharedPublicKey(base.CryptoKey):
79
110
  """DSA shared public key. This key can be shared by a group.
80
111
 
81
- BEWARE: This is raw DSA, no ECDSA/EdDSA padding, no hash, no validation!
82
- These are pedagogical/raw primitives; do not use for new protocols.
83
112
  No measures are taken here to prevent timing attacks.
84
113
 
85
114
  Attributes:
@@ -116,10 +145,43 @@ class DSASharedPublicKey(base.CryptoKey):
116
145
  string representation of DSASharedPublicKey
117
146
  """
118
147
  return ('DSASharedPublicKey('
148
+ f'bits=[{self.prime_modulus.bit_length()}, {self.prime_seed.bit_length()}], '
119
149
  f'prime_modulus={base.IntToEncoded(self.prime_modulus)}, '
120
150
  f'prime_seed={base.IntToEncoded(self.prime_seed)}, '
121
151
  f'group_base={base.IntToEncoded(self.group_base)})')
122
152
 
153
+ @property
154
+ def modulus_size(self) -> tuple[int, int]:
155
+ """Modulus size in bytes. The number of bytes used in Sign/Verify."""
156
+ return ((self.prime_modulus.bit_length() + 7) // 8,
157
+ (self.prime_seed.bit_length() + 7) // 8)
158
+
159
+ def _DomainSeparatedHash(
160
+ self, message: bytes, associated_data: bytes | None, salt: bytes, /) -> int:
161
+ """Compute the domain-separated hash for signing and verifying.
162
+
163
+ Args:
164
+ message (bytes): message to sign/verify
165
+ associated_data (bytes | None): optional associated data
166
+ salt (bytes): salt to use in the hash
167
+
168
+ Returns:
169
+ int: integer representation of the hash output;
170
+ Hash512("prefix" || len(aad) || aad || message || salt)
171
+
172
+ Raises:
173
+ CryptoError: hash output is out of range
174
+ """
175
+ aad: bytes = b'' if associated_data is None else associated_data
176
+ la: bytes = base.IntToFixedBytes(len(aad), 8)
177
+ assert len(salt) == 64, 'should never happen: salt should be exactly 64 bytes'
178
+ y: int = base.BytesToInt(
179
+ base.Hash512(_DSA_SIGNATURE_HASH_PREFIX + la + aad + message + salt))
180
+ if not 1 < y < self.prime_seed - 1:
181
+ # will only reasonably happen if prime seed is small
182
+ raise base.CryptoError(f'hash output {y} is out of range/invalid {self.prime_seed}')
183
+ return y
184
+
123
185
  @classmethod
124
186
  def NewShared(cls, p_bits: int, q_bits: int, /) -> Self:
125
187
  """Make a new shared public key of `bit_length` bits.
@@ -146,11 +208,9 @@ class DSASharedPublicKey(base.CryptoKey):
146
208
 
147
209
 
148
210
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
149
- class DSAPublicKey(DSASharedPublicKey):
211
+ class DSAPublicKey(DSASharedPublicKey, base.Verifier):
150
212
  """DSA public key. This is an individual public key.
151
213
 
152
- BEWARE: This is raw DSA, no ECDSA/EdDSA padding, no hash, no validation!
153
- These are pedagogical/raw primitives; do not use for new protocols.
154
214
  No measures are taken here to prevent timing attacks.
155
215
 
156
216
  Attributes:
@@ -176,11 +236,12 @@ class DSAPublicKey(DSASharedPublicKey):
176
236
  Returns:
177
237
  string representation of DSAPublicKey
178
238
  """
179
- return (f'DSAPublicKey({super(DSAPublicKey, self).__str__()}, ' # pylint: disable=super-with-arguments
239
+ return ('DSAPublicKey('
240
+ f'{super(DSAPublicKey, self).__str__()}, ' # pylint: disable=super-with-arguments
180
241
  f'individual_base={base.IntToEncoded(self.individual_base)})')
181
242
 
182
243
  def _MakeEphemeralKey(self) -> tuple[int, int]:
183
- """Make an ephemeral key adequate to be used with El-Gamal.
244
+ """Make an ephemeral key adequate to be used with DSA.
184
245
 
185
246
  Returns:
186
247
  (key, key_inverse), where 3 ≤ k < p_seed and (k*i) % p_seed == 1
@@ -192,9 +253,11 @@ class DSAPublicKey(DSASharedPublicKey):
192
253
  ephemeral_key = base.RandBits(bit_length - 1)
193
254
  return (ephemeral_key, modmath.ModInv(ephemeral_key, self.prime_seed))
194
255
 
195
- def VerifySignature(self, message: int, signature: tuple[int, int], /) -> bool:
256
+ def RawVerify(self, message: int, signature: tuple[int, int], /) -> bool:
196
257
  """Verify a signature. True if OK; False if failed verification.
197
258
 
259
+ BEWARE: This is raw DSA, no ECDSA/EdDSA padding, no hash, no validation!
260
+ These are pedagogical/raw primitives; do not use for new protocols.
198
261
  We explicitly disallow `message` to be zero.
199
262
 
200
263
  Args:
@@ -221,6 +284,42 @@ class DSAPublicKey(DSASharedPublicKey):
221
284
  self.individual_base, (signature[0] * inv) % self.prime_seed, self.prime_modulus)
222
285
  return ((a * b) % self.prime_modulus) % self.prime_seed == signature[0]
223
286
 
287
+ def Verify(
288
+ self, message: bytes, signature: bytes, /, *, associated_data: bytes | None = None) -> bool:
289
+ """Verify a `signature` for `message`. True if OK; False if failed verification.
290
+
291
+ • Let k = ceil(log2(n))/8 be the modulus size in bytes.
292
+ • Split signature in 3 parts: the first 64 bytes is salt, the rest is s1 and s2
293
+ • y_check = DSA(s1, s2)
294
+ • return y_check == Hash512("prefix" || len(aad) || aad || message || salt)
295
+ • return False for any malformed signature
296
+
297
+ Args:
298
+ message (bytes): Data that was signed
299
+ signature (bytes): Signature data to verify
300
+ associated_data (bytes, optional): Optional AAD (must match what was used during signing)
301
+
302
+ Returns:
303
+ True if signature is valid, False otherwise
304
+
305
+ Raises:
306
+ InputError: invalid inputs
307
+ CryptoError: internal crypto failures, authentication failure, key mismatch, etc
308
+ """
309
+ k: int = self.modulus_size[1] # use prime_seed size
310
+ if k <= 64:
311
+ raise base.InputError(f'modulus/seed too small for signing operations: {k} bytes')
312
+ if len(signature) != (64 + k + k):
313
+ logging.info(f'invalid signature length: {len(signature)} ; expected {64 + k + k}')
314
+ return False
315
+ try:
316
+ return self.RawVerify(
317
+ self._DomainSeparatedHash(message, associated_data, signature[:64]),
318
+ (base.BytesToInt(signature[64:64 + k]), base.BytesToInt(signature[64 + k:])))
319
+ except base.InputError as err:
320
+ logging.info(err)
321
+ return False
322
+
224
323
  @classmethod
225
324
  def Copy(cls, other: DSAPublicKey, /) -> Self:
226
325
  """Initialize a public key by taking the public parts of a public/private key."""
@@ -232,11 +331,9 @@ class DSAPublicKey(DSASharedPublicKey):
232
331
 
233
332
 
234
333
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
235
- class DSAPrivateKey(DSAPublicKey):
334
+ class DSAPrivateKey(DSAPublicKey, base.Signer): # pylint: disable=too-many-ancestors
236
335
  """DSA private key.
237
336
 
238
- BEWARE: This is raw DSA, no ECDSA/EdDSA padding, no hash, no validation!
239
- These are pedagogical/raw primitives; do not use for new protocols.
240
337
  No measures are taken here to prevent timing attacks.
241
338
 
242
339
  Attributes:
@@ -266,12 +363,15 @@ class DSAPrivateKey(DSAPublicKey):
266
363
  Returns:
267
364
  string representation of DSAPrivateKey without leaking secrets
268
365
  """
269
- return (f'DSAPrivateKey({super(DSAPrivateKey, self).__str__()}, ' # pylint: disable=super-with-arguments
366
+ return ('DSAPrivateKey('
367
+ f'{super(DSAPrivateKey, self).__str__()}, ' # pylint: disable=super-with-arguments
270
368
  f'decrypt_exp={base.ObfuscateSecret(self.decrypt_exp)})')
271
369
 
272
- def Sign(self, message: int, /) -> tuple[int, int]:
370
+ def RawSign(self, message: int, /) -> tuple[int, int]:
273
371
  """Sign `message` with this private key.
274
372
 
373
+ BEWARE: This is raw DSA, no ECDSA/EdDSA padding, no hash, no validation!
374
+ These are pedagogical/raw primitives; do not use for new protocols.
275
375
  We explicitly disallow `message` to be zero.
276
376
 
277
377
  Args:
@@ -294,6 +394,40 @@ class DSAPrivateKey(DSAPublicKey):
294
394
  b = (ephemeral_inv * ((message + a * self.decrypt_exp) % self.prime_seed)) % self.prime_seed
295
395
  return (a, b)
296
396
 
397
+ def Sign(self, message: bytes, /, *, associated_data: bytes | None = None) -> bytes:
398
+ """Sign `message` and return the `signature`.
399
+
400
+ • Let k = ceil(log2(n))/8 be the modulus size in bytes.
401
+ • Pick random salt of 64 bytes
402
+ • s1, s2 = DSA(Hash512("prefix" || len(aad) || aad || message || salt))
403
+ • return salt || Padded(s1, k) || Padded(s2, k)
404
+
405
+ This is basically Full-Domain Hash DSA with a 512-bit hash and per-signature salt,
406
+ which is EUF-CMA secure in the ROM. Our domain-separation prefix and explicit AAD
407
+ length prefix are both correct and remove composition/ambiguity pitfalls.
408
+ There are no Bleichenbacher-style issue because we do not expose any padding semantics.
409
+
410
+ Args:
411
+ message (bytes): Data to sign.
412
+ associated_data (bytes, optional): Optional AAD for AEAD modes; must be
413
+ provided again on decrypt
414
+
415
+ Returns:
416
+ bytes: Signature; salt || Padded(s, k) - see above
417
+
418
+ Raises:
419
+ InputError: invalid inputs
420
+ CryptoError: internal crypto failures
421
+ """
422
+ k: int = self.modulus_size[1] # use prime_seed size
423
+ if k <= 64:
424
+ raise base.InputError(f'modulus/seed too small for signing operations: {k} bytes')
425
+ salt: bytes = base.RandBytes(64)
426
+ s_int: tuple[int, int] = self.RawSign(self._DomainSeparatedHash(message, associated_data, salt))
427
+ s_bytes: bytes = base.IntToFixedBytes(s_int[0], k) + base.IntToFixedBytes(s_int[1], k)
428
+ assert len(s_bytes) == 2 * k, 'should never happen: s_bytes should be exactly 2k bytes'
429
+ return salt + s_bytes
430
+
297
431
  @classmethod
298
432
  def New(cls, shared_key: DSASharedPublicKey, /) -> Self:
299
433
  """Make a new private key based on an existing shared public key.