|
| 1 | +from nacl.signing import SigningKey, VerifyKey |
| 2 | +from nacl.public import PublicKey |
| 3 | +import nacl.bindings as salt |
| 4 | +from nacl.utils import random |
| 5 | +import nacl.hash |
| 6 | +from hashlib import blake2b |
| 7 | + |
| 8 | +from pyonionreq import xed25519 |
| 9 | + |
| 10 | +worked, trials = 0, 10000 |
| 11 | + |
| 12 | +pos_pk, neg_pk = 0, 0 |
| 13 | + |
| 14 | +for i in range(trials): |
| 15 | + s = SigningKey.generate() |
| 16 | + A = s.verify_key.encode() |
| 17 | + |
| 18 | + # This seems a bit weird, but: sodium's sk-to-curve uses the same private key scalar (a) for |
| 19 | + # both curves, so getting `a` for the curve25519 implicitly gives us the ed25519 `a` as well. |
| 20 | + a = salt.crypto_sign_ed25519_sk_to_curve25519(s.encode() + A) |
| 21 | + |
| 22 | + A_xpk = s.to_curve25519_private_key().public_key.encode() # session id is 05 + this |
| 23 | + |
| 24 | + assert salt.crypto_scalarmult_ed25519_base_noclamp(a) == A |
| 25 | + assert salt.crypto_scalarmult_base(a) == A_xpk |
| 26 | + assert salt.crypto_core_ed25519_is_valid_point(A) |
| 27 | + |
| 28 | + server_pubkey = random(32) |
| 29 | + k = salt.crypto_core_ed25519_scalar_reduce(nacl.hash.generichash(server_pubkey, digest_size=64)) |
| 30 | + |
| 31 | + ka = salt.crypto_core_ed25519_scalar_mul(k, a) |
| 32 | + # kA will be my blinded pubkey visible from my posts, with '15' prepended, and is an *Ed* |
| 33 | + # pubkey, not an X pubkey. |
| 34 | + kA = salt.crypto_scalarmult_ed25519_noclamp(k, A) |
| 35 | + |
| 36 | + assert salt.crypto_scalarmult_ed25519_base_noclamp(ka) == kA |
| 37 | + assert salt.crypto_core_ed25519_is_valid_point(kA) |
| 38 | + |
| 39 | + ##### Signing (e.g. for X-SOGS-*) with a blinded keypair ka/kA |
| 40 | + |
| 41 | + # This generation is *almost* just bog standard Ed25519 but we have one change: when generating |
| 42 | + # r we add kA into the hash r = H(H_rh || kA || M), rather than r = H(H_rh || M), so that there |
| 43 | + # is domain separation for r for different blinded keys. (H_rh here = right half of hash of the |
| 44 | + # secret key bytes.) After that we do the standard Ed25519 `r + H(R || kA || M)a` calculation, |
| 45 | + # which gives us a bog standard Ed25519 that can be verified using the kA pubkey with standard |
| 46 | + # verification code. |
| 47 | + message_to_sign = b'omg happy days' |
| 48 | + H_rh = salt.crypto_hash_sha512(s.encode())[32:] |
| 49 | + r = salt.crypto_core_ed25519_scalar_reduce(salt.crypto_hash_sha512(H_rh + kA + message_to_sign)) |
| 50 | + sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) |
| 51 | + HRAM = salt.crypto_core_ed25519_scalar_reduce( |
| 52 | + salt.crypto_hash_sha512(sig_R + kA + message_to_sign) |
| 53 | + ) |
| 54 | + sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) |
| 55 | + full_sig = sig_R + sig_s |
| 56 | + |
| 57 | + assert VerifyKey(kA).verify(message_to_sign, full_sig) |
| 58 | + |
| 59 | + ##### Sending a DM |
| 60 | + |
| 61 | + # Our user A above wants to send a SOGS DM to another user B: |
| 62 | + s2 = SigningKey.generate() |
| 63 | + B = s2.verify_key.encode() |
| 64 | + b = salt.crypto_sign_ed25519_sk_to_curve25519(s2.encode() + B) |
| 65 | + |
| 66 | + # with blinded keys: |
| 67 | + kb = salt.crypto_core_ed25519_scalar_mul(k, b) |
| 68 | + kB = salt.crypto_scalarmult_ed25519_noclamp(k, B) |
| 69 | + |
| 70 | + B_xpk = s2.to_curve25519_private_key().public_key.encode() |
| 71 | + |
| 72 | + assert salt.crypto_scalarmult_ed25519_base_noclamp(kb) == kB |
| 73 | + assert salt.crypto_core_ed25519_is_valid_point(kB) |
| 74 | + |
| 75 | + ##### Finding friends: |
| 76 | + # For example (in reality this would come directly from the known session id): |
| 77 | + friend_xpk = salt.crypto_sign_ed25519_pk_to_curve25519(A) |
| 78 | + |
| 79 | + # From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is |
| 80 | + # the positive (which is what Signal's XEd25519 conversion always uses): |
| 81 | + pk1 = xed25519.pubkey(friend_xpk) |
| 82 | + |
| 83 | + # Blind it: |
| 84 | + pk1 = salt.crypto_scalarmult_ed25519_noclamp(k, pk1) |
| 85 | + |
| 86 | + # For the negative, what we're going to get out of the above is simply the negative of pk1, so |
| 87 | + # flip the sign bit to get pk2: |
| 88 | + pk2 = pk1[0:31] + bytes([pk1[31] ^ 0b1000_0000]) |
| 89 | + |
| 90 | + # Optimization for Session: |
| 91 | + # - because the two blinded alternatives here differ by only the sign bit you can just always |
| 92 | + # force the sign bit to be 0 when looking up a blinded -> real session id. That is, when you |
| 93 | + # store it, you compute pk1 as above, then do a `pk1[31] &= 0x7f` to clear the sign bit, and |
| 94 | + # when looking up a blinded id to see if it's a friend, you also do the same bit clearing |
| 95 | + # before looking it up (which saves having to store two keys for each contact/server |
| 96 | + # combination). |
| 97 | + |
| 98 | + # This calculation is completely unnecessary and is only here to verify that going the long way |
| 99 | + # around gives the same result as the shortcut: |
| 100 | + pk2_alt = xed25519.pubkey(friend_xpk) |
| 101 | + pk2_alt = pk2_alt[0:31] + bytes([pk2_alt[31] | 0b1000_0000]) |
| 102 | + pk2_alt = salt.crypto_scalarmult_ed25519_noclamp(k, pk2_alt) |
| 103 | + assert pk2 == pk2_alt |
| 104 | + |
| 105 | + # Now, if this is really my friend, his blinded key will equal one of these blinded keys: |
| 106 | + if kA == pk1: |
| 107 | + pos_pk += 1 |
| 108 | + elif kA == pk2: |
| 109 | + neg_pk += 1 |
| 110 | + else: |
| 111 | + # not my friend |
| 112 | + print("failed; got neither ±A") |
| 113 | + continue |
| 114 | + |
| 115 | + ##### Encrypting a SOGS DM |
| 116 | + msg = 'hello 🎂' |
| 117 | + |
| 118 | + # Step one: calculate a shared secret, sending from A to B. We're going to calculate: |
| 119 | + # |
| 120 | + # BLAKE2b(a kB || kA || kB) |
| 121 | + # |
| 122 | + # from the sender, and the receiver can calculate this same value as: |
| 123 | + # |
| 124 | + # BLAKE2b(b kA || kA || kB) |
| 125 | + # |
| 126 | + enc_key = blake2b( |
| 127 | + salt.crypto_scalarmult_ed25519_noclamp(a, kB) + kA + kB, digest_size=32 |
| 128 | + ).digest() |
| 129 | + |
| 130 | + # Inner data: msg || A (i.e. the sender's ed25519 master pubkey, *not* kA blinded pubkey) |
| 131 | + plaintext = msg.encode() + A |
| 132 | + |
| 133 | + # Encrypt using xchacha20-poly1305 |
| 134 | + nonce = random(24) |
| 135 | + ciphertext = salt.crypto_aead_xchacha20poly1305_ietf_encrypt( |
| 136 | + plaintext, aad=None, nonce=nonce, key=enc_key |
| 137 | + ) |
| 138 | + |
| 139 | + data = b'\x00' + ciphertext + nonce |
| 140 | + |
| 141 | + ##### Decrypting a SOGS DM |
| 142 | + # Opening the box on the recipient end. |
| 143 | + |
| 144 | + # I receive alongside the message from sogs (i.e. this is the blinded session id minus the '15') |
| 145 | + # kA=... |
| 146 | + |
| 147 | + # Calculate the shared encryption key (see above) |
| 148 | + dec_key = blake2b( |
| 149 | + salt.crypto_scalarmult_ed25519_noclamp(b, kA) + kA + kB, digest_size=32 |
| 150 | + ).digest() |
| 151 | + |
| 152 | + assert enc_key == dec_key |
| 153 | + |
| 154 | + assert len(data) > 25 |
| 155 | + v, ct, nc = data[0], data[1:-24], data[-24:] |
| 156 | + |
| 157 | + assert v == 0x00 # Make sure our encryption version is okay |
| 158 | + |
| 159 | + # Decrypt |
| 160 | + plaintext = salt.crypto_aead_xchacha20poly1305_ietf_decrypt(ct, aad=None, nonce=nc, key=dec_key) |
| 161 | + |
| 162 | + assert len(plaintext) > 32 |
| 163 | + |
| 164 | + # Split up: the last 32 bytes are the sender's *unblinded* ed25519 key |
| 165 | + message, sender_edpk = plaintext[:-32], plaintext[-32:] |
| 166 | + |
| 167 | + # Verify that the inner sender_edpk (A) yields the same outer kA we got with the message |
| 168 | + assert kA == salt.crypto_scalarmult_ed25519_noclamp(k, sender_edpk) |
| 169 | + |
| 170 | + message = message.decode() # utf-8 bytes back to str |
| 171 | + |
| 172 | + sender_session_id = '05' + salt.crypto_sign_ed25519_pk_to_curve25519(sender_edpk).hex() |
| 173 | + |
| 174 | + assert message == msg |
| 175 | + assert sender_edpk == A |
| 176 | + assert sender_session_id == '05' + A_xpk.hex() |
| 177 | + |
| 178 | + worked += 1 |
| 179 | + |
| 180 | + |
| 181 | +print(f"{worked} successes / {trials} trials") |
| 182 | +print(f"{pos_pk} positive Ed keys, {neg_pk} negative") |
0 commit comments