├── README.md └── generate_fancy_ssh_key.py /README.md: -------------------------------------------------------------------------------- 1 | # Messages in SSH RSA keys 2 | 3 | ## Motivation 4 | I thought it'd be amusing to have a public SSH RSA key that contains a readable 5 | message and not only random-looking ASCII, if only to confuse the next guy putting his 6 | key on some machine at work... 7 | 8 | ## Prerequisites 9 | Requires the pyasn1 module. Install with ```pip install pyasn1``` 10 | 11 | ## How to use it 12 | Let's create a vanilla unencrypted (no passphrase) [RSA](http://en.wikipedia.org/wiki/RSA) 13 | key pair for SSH: 14 | 15 | ssh-keygen -t rsa -b 1024 16 | Generating public/private rsa key pair. 17 | Enter file in which to save the key: vanilla 18 | Enter passphrase (empty for no passphrase): 19 | Enter same passphrase again: 20 | Your identification has been saved in vanilla. 21 | Your public key has been saved in vanilla.pub. 22 | The key fingerprint is: 23 | 29:64:0c:5d:01:fd:f4:f7:99:a9:16:b8:8b:a3:e9:0c thi@tyr 24 | The key's randomart image is: 25 | +--[ RSA 1024]----+ 26 | | ...+o. | 27 | | o. . . | 28 | | + o . | 29 | | o .. . . | 30 | | . S .. .+| 31 | | . . . +.| 32 | | E . o | 33 | | o .... o | 34 | | .=....o | 35 | +-----------------+ 36 | 37 | Splendid. Now we have the files ''vanilla'' and ''vanilla.pub'', which contain the 38 | private and public keys respectively. This particular public key looks like this: 39 | 40 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDJaX+dfSdydMbnJrEqTipwajK8qQzKoNMXt0NAHKqeKyi+3zLEO 41 | yhB+bVAEOUcQtkDPz5aYqk/wrF+ht+mt1i80wtGCgifGvJgzZqNcRh/p7F3g9OXFzaPKHqFlksIr7GUJJXarb+Q4C 42 | mS5Qhk8ejBSZ/vR7lr58GF5Avz2zSgrQ== thi@thialfihar.org 43 | 44 | (line breaks added for readability, it's one line) 45 | 46 | And we can now put that line into some `authorized_keys` files on some machines for password-less 47 | authentication when we login remotely via SSH. 48 | 49 | ### Add a readable message 50 | 51 | > ./generate_fancy_ssh_key.py vanilla '++++thialfihar+org++++' 52 | new key pair: 53 | vanilla.new.pub 54 | vanilla.new 55 | 56 | and the resulting file might look something like this: 57 | 58 | ssh-rsa AAAAB3NzaC1yc2EAAAAZAQAA++++thialfihar+org++++++AAAANwAAAIEAyWl/nX0ncnTG5yaxKk4qc 59 | GoyvKkMyqDTF7dDQByqnisovt8yxDsoQfm1QBDlHELZAz8+WmKpP8KxfobfprdYvNMLRgoInxryYM2ajXEYf6exd4 60 | PTlxc2jyh6hZZLCK+xlCSV2q2/kOApkuUIZPHowUmf70e5a+fBheQL89s0oK0= thi@thialfihar.org 61 | 62 | All spaces will be replaced by '+'s and all other non-base64 chars will become '/'s. 63 | 64 | `NOTE:` the source key pair must be unencrypted, as the script can't read private key files 65 | with a passphrase. 66 | 67 | ## How it works 68 | ### Read the public key 69 | The public key file contains the encryption algorithm as first word: `ssh-rsa`, the last word 70 | is your name and the host the key belongs to: `thi@thialfihar.org`, and the middle is the 71 | interesting bit. 72 | 73 | The middle is a [base64](http://en.wikipedia.org/wiki/Base64) representation of a binary data 74 | structure that looks something like this: 75 | 76 | 4 bytes - unsigned int: length X of string to come 77 | X bytes - string: this will be 'ssh-rsa' (7 chars) 78 | 79 | 4 bytes - unsigned int: length Y of byte array 80 | Y bytes - bigint of 'e' 81 | 82 | 4 bytes - unsigned int: length Z of byte array 83 | Z bytes - bigint of 'n' 84 | 85 | So this is easily parsed. See wikipedia RSA link above for details of what exactly `e` and `n` 86 | are for. For now we only need to know that both are relatively large numbers and together `(e, n)` 87 | forms our public key. 88 | 89 | ### Read the private key 90 | We'll also need the private key, because it contains `p` and `q` such that `pq = n` and a key 91 | pair can only be generated if we know `p` and `q` (at least to our knowledge). 92 | 93 | I cover some details on the private key and the SSH file structure for it in the script (see bottom), 94 | so I won't go into it much here. Just that it is contained in a 95 | [DER](http://en.wikipedia.org/wiki/Distinguished_Encoding_Rules) structure. 96 | (See also [ASN.1](http://en.wikipedia.org/wiki/ASN.1)) 97 | 98 | For reading and writing that DER structure I used a Python library called 99 | [pyasn1](http://pyasn1.sourceforge.net/), which worked very well. 100 | 101 | ### Generate new public key 102 | Since `e` comes first and can actually be quite freely chosen, we just have to find some `e` that 103 | together with the other binary data will produce some readable message in the base64 encoded part 104 | of the public key. 105 | 106 | We do that by first building the beginning of the binary data up to the 4 bytes for the length of 107 | the `e` chunk, which we'll assume to be 0 for now, as we only need the bytes there and don't care 108 | what the length will be later. Then we fill up that string with 0 bytes until the total length is 109 | a multiple of 6, because every chunk of 6 bytes is encoded to exactly 8 base64 bytes without any 110 | padding. Now we encode that string in base64 and in our special case of SSH RSA keys will always 111 | get this: 112 | 113 | AAAAB3NzaC1yc2EAAAAZAQAA 114 | 115 | Now we take our message, which will be ''++++thialfihar+org++++'' in our case (keep in mind the 116 | limited base64 charset, so we have to replace spaces), and concatenate it to our base64 beginning: 117 | 118 | AAAAB3NzaC1yc2EAAAAKAQAA++++thialfihar+org++++ 119 | 120 | In order to make this a valid base64 string without padding again we need to get it to a length 121 | divisible by 8. We do this by adding more +s, and to get some separation from the rest of the key, 122 | let's add 8 '+'s even if the length is already a multiple of 8. In this case the length is 46, so we 123 | only need 2: 124 | 125 | AAAAB3NzaC1yc2EAAAAKAQAA++++thialfihar+org++++++ 126 | 127 | Now we have valid base64 encoded data, which we can decode, so we can read the binary data `e` must 128 | start with at the appropriate offset. 129 | 130 | We also have to make sure our constructed `e` will work for our purpose, for which it must suffice 131 | `gcd(e, phi(n)) == 1`, where `phi` is 132 | [Euler's totient function](http://en.wikipedia.org/wiki/Euler%27s_totient_function). 133 | We also should make sure `e` is a _good_ one. See RSA encryption for details on that, I'll ignore 134 | that for now, as I think it is incredibly improbable that the `e` we just generated is an insecure one. 135 | 136 | Now `(e, n)` forms our new public key. 137 | 138 | ### Find matching private key 139 | Of course we now need to build a new private key as well, so things will actually _work_. For this 140 | we just need to find a `d` such that `ed == 1 mod phi(n)`. This can easily be done with the 141 | [extended Euclidean algorithm](http://en.wikipedia.org/wiki/Extended_Euclidean_algorithm). 142 | Afterwards `(d, n)` forms our new private key. We also adjust some exponents needed for the DER structure. 143 | 144 | ### Write new keys 145 | Now we just pack everything back into an SSH-readable format. Generating the public and private key files. 146 | 147 | -------------------------------------------------------------------------------- /generate_fancy_ssh_key.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright (C) 2009 Thialfihar (thi@thialfihar.org) 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # http://www.gnu.org/licenses/ 16 | """ 17 | Include a readable semantic message in a given SSH RSA key, so it still can be used. 18 | Just for fun. 19 | 20 | """ 21 | import argparse 22 | import base64 23 | import random 24 | import re 25 | import struct 26 | 27 | # need pyasn for DER parsing and generating 28 | from pyasn1.type import univ 29 | from pyasn1.codec.der import decoder, encoder 30 | 31 | 32 | def gcd(a, b): 33 | """ 34 | Calculate the GCD of a and b. 35 | 36 | """ 37 | while b != 0: 38 | (b, a) = (a % b, b) 39 | 40 | return a 41 | 42 | 43 | def extended_euclidean_algorithm(a, b): 44 | """ 45 | Perform the extended euclidean algorithm, yield each step. 46 | 47 | """ 48 | if a < b: 49 | (a, b) = (b, a) 50 | 51 | while b != 0: 52 | k = a // b 53 | r = a - b * k 54 | 55 | yield (b, k, r) 56 | (a, b) = (b, r) 57 | 58 | 59 | def mod_inverse(a, m): 60 | """ 61 | Calculate the modular inverse of a in Z_m. 62 | 63 | """ 64 | # get all steps from the euclidean algorithm 65 | steps = list(extended_euclidean_algorithm(a, m)) 66 | 67 | # if gcd(a, m) != 1, then there is no inverse 68 | if steps[-1][0] != 1: 69 | raise Exception('%d has no inverse in Z_%d' % (a, m)) 70 | 71 | # calculate a^-1 (mod m) by using each step backwards 72 | steps.reverse() 73 | (a, k, r) = steps[0] 74 | for (na, k, nr) in steps[1:]: 75 | (r, a) = (a, r - k * a) 76 | 77 | inverse = a 78 | 79 | # make sure 0 < inverse < m 80 | while inverse < 0: 81 | inverse += m 82 | while inverse > m: 83 | inverse -= m 84 | 85 | return inverse 86 | 87 | 88 | def read_int(buffer, i): 89 | """ 90 | Read 32bit integer from buffer. 91 | 92 | """ 93 | (l,) = struct.unpack('!I', buffer[i:i + 4]) 94 | i += 4 95 | return (l, i) 96 | 97 | 98 | def read_chunk(buffer, i): 99 | """ 100 | Read chunk from buffer. 101 | 102 | """ 103 | # first grab length of chunk 104 | (l, i) = read_int(buffer, i) 105 | if l > 1000000: 106 | # just in case... if this happens, then something is way off 107 | raise Exception("got chunk length of %d, that's certainly too long" % l) 108 | 109 | # read chunk of length l 110 | (s,) = struct.unpack('!' + '%ds' % l, buffer[i:i + l]) 111 | i += l 112 | return (s, i) 113 | 114 | 115 | def unpack_bigint(buffer): 116 | """ 117 | Turn binary chunk into integer. 118 | 119 | """ 120 | v = 0 121 | for c in buffer: 122 | v *= 256 123 | v += ord(c) 124 | 125 | return v 126 | 127 | 128 | def pack_bigint(v): 129 | """ 130 | Pack integer into binary chunk. 131 | 132 | """ 133 | chunk = '' 134 | rest = v 135 | while rest: 136 | chunk = chr(rest % 256) + chunk 137 | rest //= 256 138 | 139 | # add a zero byte if the highest bit is 1, so it won't be negative 140 | if ord(chunk[0]) & 128: 141 | chunk = chr(0) + chunk 142 | 143 | return chunk 144 | 145 | 146 | def read_rsa_pub(filename): 147 | """ 148 | Read RSA public key file. Structure: 149 | 150 | ssh-rsa base64data user@host 151 | 152 | base64data: [7]ssh-rsa[len][e-data][len][n-data] 153 | 154 | """ 155 | [prefix, data, host] = file(filename, 'r').read().split() 156 | raw = base64.b64decode(data) 157 | 158 | # read type string 159 | i = 0 160 | (s, i) = read_chunk(raw, i) 161 | if s != 'ssh-rsa': 162 | raise Exception("expected string 'ssh-rsa' but got '%s'" % s) 163 | 164 | # grab e 165 | (s, i) = read_chunk(raw, i) 166 | e = unpack_bigint(s) 167 | 168 | # grab n 169 | (s, i) = read_chunk(raw, i) 170 | n = unpack_bigint(s) 171 | 172 | return (n, e, host) 173 | 174 | 175 | def write_rsa_pub(filename, n, e, host): 176 | """ 177 | Write RSA public key file. Structure: 178 | 179 | ssh-rsa base64data user@host 180 | 181 | base64data: [7]ssh-rsa[len][e-data][len][n-data] 182 | 183 | """ 184 | e_str = pack_bigint(e) 185 | n_str = pack_bigint(n) 186 | # pack e and n properly into the raw data 187 | raw = struct.pack('!I7sI%dsI%ds' % (len(e_str), len(n_str)), 7, 'ssh-rsa', 188 | len(e_str), e_str, len(n_str), n_str) 189 | # assemble file content and save it 190 | content = "ssh-rsa %s %s\n" % (base64.b64encode(raw), host) 191 | file(filename, 'w').write(content) 192 | 193 | 194 | def read_rsa_pri(filename): 195 | """ 196 | Read RSA private key file. Structure: 197 | 198 | -----BEGIN RSA PRIVATE KEY----- 199 | base64data 200 | -----END RSA PRIVATE KEY----- 201 | 202 | base64data DER structure: 203 | 204 | RSAPrivateKey ::= SEQUENCE { 205 | version Version, 206 | modulus INTEGER, -- n 207 | publicExponent INTEGER, -- e 208 | privateExponent INTEGER, -- d 209 | prime1 INTEGER, -- p 210 | prime2 INTEGER, -- q 211 | exponent1 INTEGER, -- d mod (p - 1) 212 | exponent2 INTEGER, -- d mod (q - 1) 213 | coefficient INTEGER -- q^-1 mod p 214 | } 215 | 216 | """ 217 | # grab only the lines between the --- * --- lines, glue them together 218 | data = ''.join(filter(lambda x: x and x[0] != '-', 219 | file(filename, 'r').read().split('\n'))) 220 | # decode from base64 221 | raw = base64.b64decode(data) 222 | # parse DER structure 223 | der = decoder.decode(raw) 224 | (version, n, e, d, p, q, e1, e2, c) = (int(x) for x in der[0]) 225 | 226 | return (n, e, d, p, q, e1, e2, c) 227 | 228 | 229 | def write_rsa_pri(filename, n, e, d, p, q, e1, e2, c): 230 | """ 231 | Write RSA private key file. Structure: 232 | 233 | -----BEGIN RSA PRIVATE KEY----- 234 | base64data 235 | -----END RSA PRIVATE KEY----- 236 | 237 | base64data DER structure: 238 | 239 | RSAPrivateKey ::= SEQUENCE { 240 | version Version, 241 | modulus INTEGER, -- n 242 | publicExponent INTEGER, -- e 243 | privateExponent INTEGER, -- d 244 | prime1 INTEGER, -- p 245 | prime2 INTEGER, -- q 246 | exponent1 INTEGER, -- d mod (p - 1) 247 | exponent2 INTEGER, -- d mod (q - 1) 248 | coefficient INTEGER -- q^-1 mod p 249 | } 250 | 251 | """ 252 | seq = (univ.Integer(0), 253 | univ.Integer(n), 254 | univ.Integer(e), 255 | univ.Integer(d), 256 | univ.Integer(p), 257 | univ.Integer(q), 258 | univ.Integer(e1), 259 | univ.Integer(e2), 260 | univ.Integer(c)) 261 | struct = univ.Sequence() 262 | for i in xrange(len(seq)): 263 | struct.setComponentByPosition(i, seq[i]) 264 | 265 | # build DER structure 266 | raw = encoder.encode(struct) 267 | # encode to base64 268 | data = base64.b64encode(raw) 269 | 270 | # chop data up into lines of certain width 271 | width = 64 272 | chopped = [data[i:i + width] for i in xrange(0, len(data), width)] 273 | # assemble file content 274 | content = """-----BEGIN RSA PRIVATE KEY----- 275 | %s 276 | -----END RSA PRIVATE KEY----- 277 | """ % '\n'.join(chopped) 278 | file(filename, 'w').write(content) 279 | 280 | 281 | def modify_key(message, n, p, q): 282 | """ 283 | Change key data to contain readable message in public key file. 284 | 285 | """ 286 | # create plausible start of binary data, fill it up with 0s to 287 | # get a multiple of 6 (for base64 encoding) 288 | base = struct.pack('!I7sIB', 7, 'ssh-rsa', 10, 1) 289 | base += chr(0) * (6 - (len(base) % 6)) 290 | base = base64.b64encode(base) 291 | 292 | # prepare message to only contain chars in a-zA-Z0-9+/, spaces become +, 293 | # any non-alphanumeric char becomes / 294 | message = re.sub(r'[^a-zA-Z0-9 +/]', '/', message).replace(' ', '+') 295 | # build a base64 char starting with our base and the message 296 | b64 = base + message 297 | 298 | # fill encoded string up to the next multiple of 8 with +s, 8 of them if 299 | # the length is already a multiple of 8 300 | nplus = 8 - (len(message) % 8) 301 | if nplus == 0: 302 | nplus = 8 303 | b64 += '+' * nplus 304 | 305 | # decode the data again to see what binary data we need 306 | raw = base64.b64decode(b64) 307 | # get binary data of what our 'e' should be and turn it into a number 308 | e_chunk = raw[4 + 7 + 4:] 309 | e = unpack_bigint(e_chunk) 310 | 311 | # make sure gcd(e, phi_n) == 1 by adding some bytes at the end if needed, 312 | # this won't change the bytes we already generated 313 | phi_n = (p - 1) * (q - 1) 314 | original_e = e 315 | while gcd(e, phi_n) != 1: 316 | e = (original_e << 32) + random.randint(0, 2 * 32) 317 | 318 | # TODO: e should probably go through a few more checks to make sure it isn't 319 | # a bad one, but the chance for that should be very small 320 | 321 | # find d such that ed == 1 mod gcd(n) for new private key 322 | d = mod_inverse(e, phi_n) 323 | 324 | # calculate exponents for private key 325 | e1 = d % (p - 1) 326 | e2 = d % (q - 1) 327 | 328 | c = mod_inverse(q, p) 329 | 330 | # we now have our new key pair 331 | return (n, e, d, p, q, e1, e2, c) 332 | 333 | 334 | def build_fancy_ssh_key(filename, message): 335 | """ 336 | Build a new SSH RSA key pair based on a key pair and a message. 337 | 338 | """ 339 | # read public and private key files 340 | (n, e, host) = read_rsa_pub(filename + '.pub') 341 | (n, e, d, p, q, e1, e2, c) = read_rsa_pri(filename) 342 | 343 | # build a new key pair based on this one 344 | (n, e, d, p, q, e1, e2, c) = modify_key(message, n, p, q) 345 | 346 | # write new key files 347 | key_pair = (filename + '.new.pub', filename + '.new') 348 | write_rsa_pub(key_pair[0], n, e, host) 349 | write_rsa_pri(key_pair[1], n, e, d, p, q, e1, e2, c) 350 | 351 | return key_pair 352 | 353 | 354 | if __name__ == "__main__": 355 | parser = argparse.ArgumentParser() 356 | parser.add_argument('key_name', help="name of the private key file, the .pub file must live next to it") 357 | parser.add_argument('message', nargs="*", help="the message to add to the key") 358 | 359 | args = parser.parse_args() 360 | 361 | key_pair = build_fancy_ssh_key(args.key_name, ' '.join(args.message)) 362 | print 'new key pair:\n%s' % '\n'.join(key_pair) 363 | --------------------------------------------------------------------------------