|
| 1 | +"""Shared SHA-crypt implementation for the "$5$"/"$6$" password formats. |
| 2 | +
|
| 3 | +"$5$" (sha256-crypt) and "$6$" (sha512-crypt) are the same algorithm |
| 4 | +parameterised only by which hash function feeds it, so it is implemented once |
| 5 | +here and passed a hash name, exactly as `_cisco_hash.py` shares framing |
| 6 | +between Cisco type 8 and type 9 and takes a KDF. |
| 7 | +
|
| 8 | +Spec: Ulrich Drepper, "Unix crypt using SHA-256 and SHA-512" |
| 9 | +(https://www.akkadia.org/drepper/SHA-crypt.txt). The digest is produced by a |
| 10 | +long chain of intermediate hashes (digests A/B/DP/DS feeding a `rounds`-long |
| 11 | +loop over digest C), then encoded with a base64 variant unrelated to |
| 12 | +standard base64 or to Cisco's variant in `_cisco_hash.py`: 3-byte groups |
| 13 | +become 4 characters using little-endian bit order within the group, and the |
| 14 | +byte that fills each of the three group positions is picked by a shuffled, |
| 15 | +variant-specific permutation rather than taken in sequence. Both the |
| 16 | +digest-chain logic and the permutation tables below were verified byte-for- |
| 17 | +byte against `openssl passwd -5`/`-6` output, including custom `rounds=` |
| 18 | +values (see tests/test_juniper_encrypted_password.py). |
| 19 | +
|
| 20 | +This module is internal: it is not exported from network_secret. It performs |
| 21 | +no bounds checking on `rounds` - the caller (juniper_encrypted_password.py) |
| 22 | +must enforce MIN_ROUNDS/MAX_ROUNDS before calling `hash_password`, since |
| 23 | +`rounds` here is a plain multiplier on the amount of hashing work done. |
| 24 | +""" |
| 25 | + |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +import hashlib |
| 29 | +import secrets |
| 30 | + |
| 31 | +ALPHABET = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" |
| 32 | +SALT_LEN = 16 # characters generated for a fresh salt |
| 33 | + |
| 34 | +_DIGEST_SIZES = {"sha256": 32, "sha512": 64} |
| 35 | + |
| 36 | +# Byte-index permutations feeding _b64_from_24bit(b2, b1, b0, n), taken from |
| 37 | +# Drepper's reference sha256-crypt.c / sha512-crypt.c. Each 3-tuple names |
| 38 | +# which digest byte fills the high/mid/low position of one 3-byte group. |
| 39 | +_SHA256_GROUPS = [ |
| 40 | + (0, 10, 20), (21, 1, 11), (12, 22, 2), (3, 13, 23), (24, 4, 14), |
| 41 | + (15, 25, 5), (6, 16, 26), (27, 7, 17), (18, 28, 8), (9, 19, 29), |
| 42 | +] |
| 43 | +_SHA256_LAST = (None, 31, 30) # final, partial group: 3 output characters |
| 44 | + |
| 45 | +_SHA512_GROUPS = [ |
| 46 | + (0, 21, 42), (22, 43, 1), (44, 2, 23), (3, 24, 45), (25, 46, 4), |
| 47 | + (47, 5, 26), (6, 27, 48), (28, 49, 7), (50, 8, 29), (9, 30, 51), |
| 48 | + (31, 52, 10), (53, 11, 32), (12, 33, 54), (34, 55, 13), (56, 14, 35), |
| 49 | + (15, 36, 57), (37, 58, 16), (59, 17, 38), (18, 39, 60), (40, 61, 19), |
| 50 | + (62, 20, 41), |
| 51 | +] |
| 52 | +_SHA512_LAST = (None, None, 63) # final, partial group: 2 output characters |
| 53 | + |
| 54 | + |
| 55 | +def _b64_from_24bit(b2: int, b1: int, b0: int, n: int) -> str: |
| 56 | + """Encode one 3-byte group (b2 high, b0 low) as n base64 characters. |
| 57 | +
|
| 58 | + Unlike standard base64, the 6-bit groups are emitted low-order first. |
| 59 | + """ |
| 60 | + word = (b2 << 16) | (b1 << 8) | b0 |
| 61 | + chars = [] |
| 62 | + for _ in range(n): |
| 63 | + chars.append(ALPHABET[word & 0x3F]) |
| 64 | + word >>= 6 |
| 65 | + return "".join(chars) |
| 66 | + |
| 67 | + |
| 68 | +def encode_digest(digest: bytes, hash_name: str) -> str: |
| 69 | + """Encode a raw sha256/sha512 digest with SHA-crypt's base64 variant.""" |
| 70 | + if hash_name == "sha256": |
| 71 | + groups, last, last_n = _SHA256_GROUPS, _SHA256_LAST, 3 |
| 72 | + elif hash_name == "sha512": |
| 73 | + groups, last, last_n = _SHA512_GROUPS, _SHA512_LAST, 2 |
| 74 | + else: |
| 75 | + raise ValueError(f"Unsupported hash function: {hash_name!r}") |
| 76 | + out = [ |
| 77 | + _b64_from_24bit(digest[b2], digest[b1], digest[b0], 4) |
| 78 | + for b2, b1, b0 in groups |
| 79 | + ] |
| 80 | + b2, b1, b0 = last |
| 81 | + out.append( |
| 82 | + _b64_from_24bit( |
| 83 | + 0 if b2 is None else digest[b2], |
| 84 | + 0 if b1 is None else digest[b1], |
| 85 | + digest[b0], |
| 86 | + last_n, |
| 87 | + ) |
| 88 | + ) |
| 89 | + return "".join(out) |
| 90 | + |
| 91 | + |
| 92 | +def generate_salt(length: int = SALT_LEN) -> str: |
| 93 | + """Return a fresh `length`-character salt drawn from the crypt alphabet.""" |
| 94 | + return "".join(secrets.choice(ALPHABET) for _ in range(length)) |
| 95 | + |
| 96 | + |
| 97 | +def hash_password(password: str, salt: str, rounds: int, hash_name: str) -> bytes: |
| 98 | + """Compute the raw SHA-crypt digest for `password`/`salt`/`rounds`. |
| 99 | +
|
| 100 | + `rounds` is used exactly as given, with no bounds enforced here - see the |
| 101 | + module docstring. |
| 102 | + """ |
| 103 | + digest_size = _DIGEST_SIZES[hash_name] |
| 104 | + pw = password.encode("utf-8") |
| 105 | + slt = salt.encode("ascii") |
| 106 | + |
| 107 | + ctx_a = hashlib.new(hash_name) |
| 108 | + ctx_a.update(pw) |
| 109 | + ctx_a.update(slt) |
| 110 | + |
| 111 | + ctx_b = hashlib.new(hash_name) |
| 112 | + ctx_b.update(pw) |
| 113 | + ctx_b.update(slt) |
| 114 | + ctx_b.update(pw) |
| 115 | + digest_b = ctx_b.digest() |
| 116 | + |
| 117 | + plen = len(pw) |
| 118 | + remaining = plen |
| 119 | + while remaining > digest_size: |
| 120 | + ctx_a.update(digest_b) |
| 121 | + remaining -= digest_size |
| 122 | + ctx_a.update(digest_b[:remaining]) |
| 123 | + |
| 124 | + remaining = plen |
| 125 | + while remaining > 0: |
| 126 | + if remaining & 1: |
| 127 | + ctx_a.update(digest_b) |
| 128 | + else: |
| 129 | + ctx_a.update(pw) |
| 130 | + remaining >>= 1 |
| 131 | + |
| 132 | + digest_a = ctx_a.digest() |
| 133 | + |
| 134 | + ctx_dp = hashlib.new(hash_name) |
| 135 | + for _ in range(plen): |
| 136 | + ctx_dp.update(pw) |
| 137 | + digest_dp = ctx_dp.digest() |
| 138 | + |
| 139 | + p_seq = bytearray() |
| 140 | + remaining = plen |
| 141 | + while remaining > digest_size: |
| 142 | + p_seq += digest_dp |
| 143 | + remaining -= digest_size |
| 144 | + p_seq += digest_dp[:remaining] |
| 145 | + p_bytes = bytes(p_seq) |
| 146 | + |
| 147 | + ctx_ds = hashlib.new(hash_name) |
| 148 | + for _ in range(16 + digest_a[0]): |
| 149 | + ctx_ds.update(slt) |
| 150 | + digest_ds = ctx_ds.digest() |
| 151 | + |
| 152 | + slen = len(slt) |
| 153 | + s_seq = bytearray() |
| 154 | + remaining = slen |
| 155 | + while remaining > digest_size: |
| 156 | + s_seq += digest_ds |
| 157 | + remaining -= digest_size |
| 158 | + s_seq += digest_ds[:remaining] |
| 159 | + s_bytes = bytes(s_seq) |
| 160 | + |
| 161 | + digest_c = digest_a |
| 162 | + for round_num in range(rounds): |
| 163 | + ctx_c = hashlib.new(hash_name) |
| 164 | + ctx_c.update(p_bytes if round_num % 2 else digest_c) |
| 165 | + if round_num % 3: |
| 166 | + ctx_c.update(s_bytes) |
| 167 | + if round_num % 7: |
| 168 | + ctx_c.update(p_bytes) |
| 169 | + ctx_c.update(digest_c if round_num % 2 else p_bytes) |
| 170 | + digest_c = ctx_c.digest() |
| 171 | + |
| 172 | + return digest_c |
0 commit comments