|
| 1 | +--- |
| 2 | +title: "SIS Password Hash Security" |
| 3 | +sidebar_position: 2 |
| 4 | +id: sis-password-hashes |
| 5 | +author: John Z. Walthall |
| 6 | +published: "" |
| 7 | +edited: "" |
| 8 | +--- |
| 9 | + |
| 10 | +## Introduction |
| 11 | + |
| 12 | +You can create or set user passwords by SIS using the 'Person' data type. There are three ways to do this: |
| 13 | + |
| 14 | +1. Plaintext (not recommended) |
| 15 | +2. MD5 (not recommended) |
| 16 | +3. Salted SHA-1 ("SSHA", recommended) |
| 17 | + |
| 18 | +Here is an example of all three formats. (In each case: the password is 'cyan') |
| 19 | + |
| 20 | +``` |
| 21 | +user_id|external_person_key|lastname|firstname|passwd|pwencryptiontype|data_source_key |
| 22 | +jshaw|jshaw|Shaw|James|{SSHA}foV2dGZ/2FLNdmJUNEpXZ8ijfiGAriwuB9AYrQ==|SSHA|exterminate |
| 23 | +jplain|jplain|Plain|Jane|cyan||exterminate |
| 24 | +md5|md5|Five|Maddy|6411532ba4971f378391776a9db629d3|MD5|exterminate |
| 25 | +``` |
| 26 | + |
| 27 | +## Supplying a Password Hash |
| 28 | + |
| 29 | +These hash formats are not the one used by the LMS. When a password is set in the GUI it is hashed using a |
| 30 | +[512-bit SHA-2-based](https://help.blackboard.com/Learn/Administrator/SaaS/Security/Identification_Authentication) hash. |
| 31 | + |
| 32 | +If you supply the field `passwd`, but not the field `pwencryptiontype` (or: set this field to blank) the input will be |
| 33 | +hashed by the LMS using the aforementioned hash. |
| 34 | + |
| 35 | +Otherwise, the input will be copied verbatim into the database. When the user logs in: which is the only time the clear- |
| 36 | +text of the password is accessible to Blackboard, the cleartext will be re-hashed using a strong hash and the existing |
| 37 | +one overwritten. |
| 38 | + |
| 39 | +If you set 'change on update' for the password field: the stronger hash will be overwritten. |
| 40 | + |
| 41 | +### Using MD5 |
| 42 | + |
| 43 | +:::danger |
| 44 | +It's not recommended to use MD5. MD5 is obsolete and no longer secure. It may be subject to removal in a future version |
| 45 | +of Blackboard Learn. |
| 46 | +::: |
| 47 | + |
| 48 | +The MD5 hash is trivial: it's simply the standard hex representation of the MD5sum of the string. So if the password is |
| 49 | +'cyan' then |
| 50 | + |
| 51 | +```shell |
| 52 | +$ echo -n "cyan" | md5 |
| 53 | +6411532ba4971f378391776a9db629d3 |
| 54 | +``` |
| 55 | + |
| 56 | +### Using SSHA |
| 57 | + |
| 58 | +This format is an informal standard and our implementation is _similar to_ but _not the same as_ [the format used by |
| 59 | +`slappasswd(8)`](https://github.com/openldap/openldap/blob/34813d9cba02a74216a784636a8d5f0f986d73c7/libraries/liblutil/passwd.c#L749-L779) |
| 60 | +from the OpenLDAP project. The key difference is the salt-size. Blackboard Learn uses an **8-byte** salt. OpenLDAP uses |
| 61 | +a **4-byte** salt. Since the salt-size is not stored in the format, hashes with different salt-lengths are not |
| 62 | +compatible. |
| 63 | +Therefore, the hashes generated by `slappasswd(8)` cannot be used. |
| 64 | + |
| 65 | +### Algorithm Description |
| 66 | + |
| 67 | +1. For each password: create 8 random bytes of 'salt'. Never re-use salts. Use a Cryptographically Secure Psuedo-Random |
| 68 | + Number Generator, or a true environmentally sourced random-number generator. |
| 69 | +2. Convert the password string to a byte array using UTF-8. |
| 70 | +3. Digest the concatenation of the password bytes + the salt bytes as SHA-1. The SHA-1 digest must be a byte array |
| 71 | + itself, if the implementation produces a hex string, it must be converted to a byte array by parsing each pair of |
| 72 | + characters as an unsigned hexadecimal byte. |
| 73 | +4. Append another copy of the same salt bytes. |
| 74 | +5. Encode this as Base64 in ASCII. Use the default dictionary: not the 'url-safe' one. |
| 75 | +7. Prefix with `{SSHA}`. |
| 76 | + |
| 77 | +### Step by Step |
| 78 | + |
| 79 | +1. Let the password be `nucleus` |
| 80 | +2. Let the salt be `[21, F0, 25, 09, 15, D2, 68, 1F]` (interpreted as an array of unsigned bytes.) Remember: it must |
| 81 | + always be eight new random bytes. |
| 82 | +3. The UTF-8 bytes of 'nucleus' are `[6E, 75, 63, 6C, 65, 75, 73]` |
| 83 | +4. Thus the value to be digested is `[6E, 75, 63, 6C, 65, 75, 73, 21, F0, 25, 09, 15, D2, 68, 1F]` |
| 84 | +5. The SHA-1 digest of this (interpreted as an array of unsigned bytes) is |
| 85 | + `[90, FC, 6D, A2, C9, EA, 04, 10, 83, 20, C4, AC, 15, 73, A7, 49, BD, 88, 7A, 63]` |
| 86 | +6. Appending the salt to that is |
| 87 | + `[90, FC, 6D, A2, C9, EA, 04, 10, 83, 20, C4, AC, 15, 73, A7, 49, BD, 88, 7A, 63, 21, F0, 25, 09, 15, D2, 68, 1F]` |
| 88 | +7. The Base64 encoding of this, with prefix is `{SSHA}kPxtosnqBBCDIMSsFXOnSb2IemMh8CUJFdJoHw==` |
| 89 | + |
| 90 | +### Code Examples |
| 91 | + |
| 92 | +Here are some code examples. |
| 93 | + |
| 94 | +:::caution |
| 95 | +A particular source of confusion, and many examples on the Internet stumble on this confusion, is that the SHA-1 output |
| 96 | +must be bytes, not a hex string. If the programming language or library you choose returns a hex string for SHA-1, then |
| 97 | +it must be recast as a byte array. That is to say: `EF` must be a single byte: `239` (unsigned), not a pair of bytes |
| 98 | +equal to unsigned `69` (the '`E`') and `70` (the '`F`'.) |
| 99 | +::: |
| 100 | + |
| 101 | +:::info |
| 102 | +According to the SHA-1 specification: an empty string can be hashed. You must not do this. In the SIS API, an empty |
| 103 | +password indicates the LMS should generate a random password. Setting the password to the SSHA hash of empty string |
| 104 | +results in a blank password being set. |
| 105 | +::: |
| 106 | + |
| 107 | +#### Java |
| 108 | + |
| 109 | +```java |
| 110 | +import java.nio.charset.StandardCharsets; |
| 111 | +import java.security.MessageDigest; |
| 112 | +import java.security.NoSuchAlgorithmException; |
| 113 | +import java.security.SecureRandom; |
| 114 | +import java.util.Arrays; |
| 115 | +import java.util.Base64; |
| 116 | + |
| 117 | +/** |
| 118 | + * Encoder for our nonstandard SSHA variant using 8-byte salt. |
| 119 | + */ |
| 120 | +public final class VariantSSHA { |
| 121 | + |
| 122 | + /** |
| 123 | + * Encode string to the hash |
| 124 | + * @param password The password to encode |
| 125 | + * @return the variant-SSHA hash. |
| 126 | + */ |
| 127 | + public static String variantSSHAEncode(String password) { |
| 128 | + if (password.isBlank()) { |
| 129 | + // empty-string password has a special meaning in Learn. But a hash of empty-string is a valid hash |
| 130 | + // bail out to preserve semantics |
| 131 | + throw new IllegalArgumentException("Password cannot be blank"); |
| 132 | + } |
| 133 | + // The salt is always 8 bytes. This is incompatible with slappasswd(8) |
| 134 | + byte[] salt = new byte[8]; |
| 135 | + SecureRandom rand = null; |
| 136 | + try { |
| 137 | + rand = SecureRandom.getInstanceStrong(); |
| 138 | + } catch (NoSuchAlgorithmException e) { |
| 139 | + throw new RuntimeException("FATAL: can't load secure randomness", e); |
| 140 | + } |
| 141 | + rand.nextBytes(salt); |
| 142 | + |
| 143 | + byte[] bytesUTF8 = password.getBytes(StandardCharsets.UTF_8); |
| 144 | + |
| 145 | + try { |
| 146 | + // hash the combination of the password + the salt. |
| 147 | + MessageDigest sha1Digest = MessageDigest.getInstance("SHA1"); |
| 148 | + sha1Digest.update(bytesUTF8); |
| 149 | + sha1Digest.update(salt); |
| 150 | + byte[] binaryHash = sha1Digest.digest(); |
| 151 | + |
| 152 | + // append the salt to the hash |
| 153 | + byte[] hashPlusSalt = new byte[binaryHash.length + salt.length]; |
| 154 | + System.arraycopy(binaryHash, 0, hashPlusSalt, 0, binaryHash.length); |
| 155 | + System.arraycopy(salt, 0, hashPlusSalt, binaryHash.length, salt.length); |
| 156 | + |
| 157 | + // This is mostly security theater; but customary |
| 158 | + Arrays.fill(salt, (byte) 0); |
| 159 | + Arrays.fill(bytesUTF8, (byte) 0); |
| 160 | + |
| 161 | + String stringHash = "{SSHA}" + Base64.getEncoder().encodeToString(hashPlusSalt); |
| 162 | + Arrays.fill(hashPlusSalt, (byte) 0); |
| 163 | + |
| 164 | + return stringHash; |
| 165 | + } catch (NoSuchAlgorithmException e) { |
| 166 | + // can't happen. JSSE requires all implementing runtimes to support SHA-1 |
| 167 | + throw new RuntimeException(e); |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + /** |
| 172 | + * Convenience method to use as a simple utility. Output format is {@code original_password + tab + SSHA} |
| 173 | + * |
| 174 | + * <pre> |
| 175 | + * $ java VariantSSHA.java the quick brown fox" "jumps over the lazy dog" "when zombies arrive" "quickly fax judge patty" |
| 176 | + * the quick brown fox {SSHA}r+QLZ86dFWWp0oXhGC3nW5U/p08DvFVyKH1M/w== |
| 177 | + * jumps over the lazy dog {SSHA}yxUScjSM42EBpL2qB7I2wLf/CLHBQX0No18z/w== |
| 178 | + * when zombies arrive {SSHA}Yvot6sr1F7XNahlwY0KeXmmukpw19oYSJnZhRQ== |
| 179 | + * quickly fax judge patty {SSHA}f01o7IJGet6TzvizERwuVzPX7Ud09Pu3HGJeZg== |
| 180 | + * </pre> |
| 181 | + * @param args strings to encode. |
| 182 | + */ |
| 183 | + static void main(String... args) { |
| 184 | + if (args.length == 0) { |
| 185 | + System.err.println("Try again"); |
| 186 | + System.err.println("Usage: java VariantSSHA password1 password2 password3..."); |
| 187 | + } else { |
| 188 | + int pad = Arrays.stream(args).mapToInt(String::length).max().orElse(10); |
| 189 | + for (String password : args) { |
| 190 | + System.out.printf("%" + pad + "s\t%s%n", password, variantSSHAEncode(password)); |
| 191 | + } |
| 192 | + } |
| 193 | + } |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +#### Python |
| 198 | + |
| 199 | +```python |
| 200 | +#!/usr/bin/env python3 |
| 201 | +""" |
| 202 | +Generate Blackboard's nonstandard 8-byte-salted SSHA variant. |
| 203 | +""" |
| 204 | +import base64 |
| 205 | +import hashlib |
| 206 | +import os |
| 207 | +import sys |
| 208 | + |
| 209 | + |
| 210 | +def variant_ssha_encode(password: str): |
| 211 | + if password == "": |
| 212 | + raise RuntimeError("Password can not be empty") |
| 213 | + |
| 214 | + # Generate 8 random bytes of salt. os.urandom is cryptographically secure |
| 215 | + salt = os.urandom(8) |
| 216 | + |
| 217 | + # convert the password to bytes |
| 218 | + bytes_utf8 = password.encode("utf-8") |
| 219 | + |
| 220 | + sha1digest = hashlib.sha1() |
| 221 | + |
| 222 | + # equivalent to byte_utf8 + salt |
| 223 | + sha1digest.update(bytes_utf8) |
| 224 | + sha1digest.update(salt) |
| 225 | + |
| 226 | + # append the salt to the end of the SHA-1 digest (note the digest must be bytes, not hex string) |
| 227 | + hash_with_salt = sha1digest.digest() + salt |
| 228 | + |
| 229 | + # encode to base64, pre-fix the identifier and return. |
| 230 | + return "{SSHA}" + base64.b64encode(hash_with_salt).decode("ascii") |
| 231 | + |
| 232 | + |
| 233 | +def main(): |
| 234 | + """ |
| 235 | + Convenience method to use as a simple utility |
| 236 | + invocation: |
| 237 | + $ python3 generate_ssha_hash.py "the quick brown fox" "jumps over the lazy dog" "when zombies arrive" "quickly fax judge patty" |
| 238 | + the quick brown fox {SSHA}Ffy5dpkMeMIiebd+Sqtu0FJOV6xdAh4Wp9aeSA== |
| 239 | + jumps over the lazy dog {SSHA}SmYwGocJidrBS9AfBid9P/JUUOxhTZLylWcKQw== |
| 240 | + when zombies arrive {SSHA}layQWCu+uVrFmXeKE4ZeqPGzCJ87OVI0zAnjJQ== |
| 241 | + quickly fax judge patty {SSHA}IJbtvQYh6TocBq5m4yoU0sVRvUdMrR+hZUHxCQ== |
| 242 | + """ |
| 243 | + if len(sys.argv) == 1: |
| 244 | + print("Try again\nUsage: python3 generate_ssha_hash.py password1 password2 password3...") |
| 245 | + sys.exit(1) |
| 246 | + passwords = sys.argv[1:] |
| 247 | + pad = len(max(passwords, key=len)) |
| 248 | + for pw in passwords: |
| 249 | + ssha = variant_ssha_encode(pw) |
| 250 | + padded = pw.rjust(pad) |
| 251 | + print(f"{padded}\t{ssha}") |
| 252 | + |
| 253 | + |
| 254 | +if __name__ == "__main__": |
| 255 | + main() |
| 256 | +``` |
0 commit comments