Skip to content

Instantly share code, notes, and snippets.

@mildsunrise
Last active May 11, 2025 10:18
Show Gist options
  • Save mildsunrise/7e5b7f755e99fca5639619e1a6152679 to your computer and use it in GitHub Desktop.
Save mildsunrise/7e5b7f755e99fca5639619e1a6152679 to your computer and use it in GitHub Desktop.
Documentation + readable implementation of BK7231 flash encryption

BK7231 flash structure

When BK7231 MCUs load the SPI flash contents into memory, they do not simply copy the bytes; every 32 bytes are followed by 2 bytes of CRC, which the bootloader supposedly checks and removes. In addition, the flash contents can optionally be encrypted with a key stored in eFuse, so the bootloader would also decrypt them before copying to memory.

In the other direction, both of these tasks are performed by the encrypt tool in the SDK: this takes the built code, inserts CRC bytes and (optionally) encrypts with the eFuse key. This image is almost but not quite ready to flash: some spots of the image must then be overwritten with bootloader (RBL) and partition table (FAL) markers; these are stored in plaintext so they can be interpreted before decryption has taken place. These markers respect the 2-byte CRCs, so they are discontinuous in the flash memory if they're bigger than 1 block, and cause the CRC numbers to be updated.

The encryption consists of an ad-hoc stream cipher which is applied in 32-bit blocks, described below. The input to the stream cipher is the address of the 32-bit block, in bytes. CRC bytes do not have encryption applied to them, nor they count towards the block addresses; so, the addresses passed to the cipher are always a multiple of 4.

The CRC is calculated after the block has been encrypted, so there's no authentication at all. The polynomial is

Cipher overview

Based on abc.c, which seems to be a direct C transpilation of the HDL.

From user's perspective, the cipher takes a key in eFuse, which is made up of 4 u32s. in order these are:

  • key used for stage 3
  • key used to derive the stage 1 and 2 keys
  • key used for stage 4 (static key)
  • encryption parameters

The cipher works by XORing the output of 4 stages, each of which takes a stage-specific key, the input address and a 2-bit selector. The key and output of a stage have the same width: 16 bits for stage 1, 17 bits for stage 2, and 32 bits for stages 3 and 4.

The eFuse key is used to derive the stage keys for the 4 stages: for stage 3 and 4, the key comes directly from one of the words in the eFuse key, but for stages 1 and 2, bits of the second word of the eFuse key (and one bit of the encryption parameters) assemble their keys. Check the code for the actual expression.

The stages all have a common structure (explained below) except for the last stage, which simply outputs its key unchanged. This means the stage 4 key (the third integer in the eFuse key) is a static key; it is XORed with all ciphertexts (unless the stage is disabled, see below).

Encryption parameters is a bitfield with the following structure:

(MSB)                             (LSB)
  EEEEEEEE ........ ...SS.SS .SSKBBBB
  [------]             [] []  []|||||
     |                 |  |   | ||||+- Stage 1 bypass
     |                 |  |   | |||+-- Stage 2 bypass
     |                 |  |   | ||+--- Stage 3 bypass
     |                 |  |   | |+---- Stage 4 bypass
     |                 |  |   | +----- Bit participating in stage 2 key
     |                 |  |   +------- Stage 1 selector
     |                 |  +----------- Stage 2 selector
     |                 +-------------- Stage 3 selector
     +-------------------------------- Must be != then 00 or FF for
                                       encryption to be enabled

If the bypass bit (B) of a stage is set to 1, the output of that stage is ignored and doesn't participate in the ciphertext.

Each stage starts by preprocessing the input address in a certain way (which depends on its 2-bit selector SS) and XORing the result with the stage key. It then performs a stage-specific transform on that.

Any other parameter bits (.) are ignored.

# UTILITIES
# ---------
class FixedUint(int):
''' an unsigned integer that carries a bit width '''
width: int
def __new__(cls, x: int, width: int):
''' wraps an integer as a FixedUint (throws if out of range) '''
assert (x >> width) == 0
self = super(FixedUint, cls).__new__(cls, x)
self.width = width
return self
@classmethod
def of(cls, x: int, n: int):
''' like the constructor, but discards higher bits rather than throwing '''
return cls(x & ~((~0) << n), n)
def ror(self, k: int):
''' rotate right by k bits (k must be between 0 and width) '''
assert 0 <= k <= self.width
return self.of((self >> k) | (self << (self.width - k)), self.width)
# XORing two FixedUints of the same width preserves it
def __xor__(self, other: int):
result = int.__xor__(self, other)
if isinstance(other, FixedUint) and other.width == self.width:
result = type(self)(result, self.width)
return result
def bits(self, start: int, count: int):
''' extract n bits starting at bit k '''
assert start >= 0 and count >= 0 and start + count <= self.width
return type(self).of(self >> start, count)
def bit(self, bit: int):
''' extract bit k of x '''
return self.bits(bit, 1)
def repeat(self, count: int):
''' concatenate 'count' copies of self '''
return concat(*([self] * count))
# conditionally swap the two bytes of a u16
swap_u16 = lambda x, swap: FixedUint(x, 16).ror(8 if swap else 0)
# concatenate the bits of several uints
# (the most significant one is passed first)
def concat(*xs: FixedUint):
result, width = 0, 0
for x in xs:
result = (result << x.width) | x
width += x.width
return FixedUint(result, width)
# BK7231 FLASH ENCRYPTION
# -----------------------
def stage1(key, addr, param):
key ^= swap_u16(addr.bits(0, 16), param&1) ^ swap_u16(addr.bits(16, 16), param&2)
x = key.bits(5, 4).repeat(4)
return (key.ror(7) ^ (0x6371 & x)) << 16
def stage2(key, addr, param):
key ^= addr.bits(param, 17)
x = concat(*map(key.bit, [4] + [1, 5, 9, 13] * 4))
return (key.ror(10) ^ (0x13659 & x)) & 0xFFFF
def stage3(key, addr, param):
key ^= addr.ror(8 * param)
x = key.bits(2, 4).repeat(8)
return (key.ror(15) ^ (0xE519A4F1 & x))
def stage4(key, addr, param):
return key
stages = stage1, stage2, stage3, stage4
# given the four 32-bit integers forming the key in eFuse,
# calculates the 32-bit ciphertext (to XOR with) at a byte address:
def encrypt(fuse_key: tuple[int, int, int, int], addr: int) -> int:
addr = FixedUint(addr, 32)
key1, key2, key3, params = map(lambda x: FixedUint(x, 32), fuse_key)
if params.bits(24, 8) in {0, 0xFF}:
return 0
# derive the keys for each stage
stage_keys = [
key2.bits(16, 16),
concat(key2.bits(8, 8), params.bit(4), key2.bits(0, 8)),
key1,
key3,
]
result = 0
for n, (stage, key) in enumerate(zip(stages, stage_keys)):
if not params.bit(n):
result ^= stage(key, addr, params.bits(5 + n*3, 2))
return result
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment