Hardware-Bound Storage Encryption Using LUKS and CPU-Unique IDs

January 2026 ~15 min read Advanced

So this was my first time building a full-disk encryption solution for embedded Linux devices using LUKS. The goal was to securely bind the encrypted storage to the specific hardware, preventing unauthorized access even if the storage medium (e.g., SD card) was removed and read on another device. After researching various methods, I devised a solution that leverages the CPU's unique identifier (CPUID) stored in One-Time Programmable (OTP) memory to derive encryption keys. This approach ensures that the encrypted data can only be decrypted on the original device, enhancing security without requiring user interaction during boot. This is a modified version of the Rockchip anti-copy mechanism, adapted for LUKS and embedded Linux systems.

The Problem: Storage Security in Embedded Systems

Traditional full-disk encryption solutions face several challenges in embedded environments:

  1. Key Storage: Where do you store the encryption key securely?
  2. Hardware Binding: How do you prevent SD card cloning or device impersonation?
  3. Build-time Constraints: BitBake/Yocto builds run without root privileges
  4. Memory Limitations: Embedded devices have limited RAM for encryption operations
  5. First Boot Experience: The device must be usable immediately after flashing

Our solution addresses all these challenges using a novel approach: CPUID-derived encryption keys with first-boot re-keying.

Architecture Overview

Core Concept

Instead of storing encryption keys, we derive them from the CPU's unique identifier (CPUID) burned into OTP (One-Time Programmable) memory. This creates an unbreakable bond between the storage and the specific hardware.

System Flow

flowchart LR subgraph Build["1. Build Time"] A[Yocto/BitBake] --> A1[Unencrypted
rootfs] end subgraph Encrypt["2. Pre-Flash"] B[Encrypt with
LUKS] --> B1[Temp key
added] end subgraph First["3. First Boot"] C[Try CPUID
fails] --> C1[Try temp
succeeds] --> C2[Mount
rootfs] C2 --> C3[Read
CPUID] --> C4[Add CPUID
key] --> C5[Remove
temp key] end subgraph Normal["4. Normal Boots"] D[Read
CPUID] --> D1[Derive
passphrase] --> D2[Unlock
LUKS] --> D3[Boot] end Build --> Encrypt --> First --> Normal style Build fill:#2d3748 style Encrypt fill:#2d3748 style First fill:#2d3748 style Normal fill:#2d3748

Technical Implementation

1. Yocto Layer Structure

We created a custom Yocto layer (meta-embedded-luks) with the following structure:

meta-embedded-luks/
├── classes/
│   └── luks-gpt-image.bbclass                  # Image creation class
├── conf/
│   └── layer.conf                              # Layer configuration
├── recipes-bsp/
│   └── bootloader/                             # Bootloader modifications
├── recipes-core/
│   ├── first-boot-rekey/                       # Re-keying service
│   │   ├── first-boot-rekey.bb
│   │   └── files/
│   │       ├── first-boot-rekey.sh
│   │       └── first-boot-rekey.service
│   ├── images/
│   │   ├── luks-initramfs-image.bb            # Initramfs recipe
│   │   └── embedded-system-image.bbappend
│   └── initrdscripts/
│       ├── initramfs-framework_%.bbappend
│       └── initramfs-framework/
│           └── cryptfs                         # LUKS unlock module

2. BitBake Class: GPT Image with LUKS Support

The luks-gpt-image.bbclass handles partition layout for the embedded system. The design separates bootloader stages, ARM trusted firmware, and the boot partition (FAT32, unencrypted for bootloader access) from the rootfs partition which occupies remaining space and gets LUKS-encrypted with AES-XTS-256.

# local.conf or image recipe configuration
IMAGE_CLASSES += "luks-gpt-image"

# Partition layout
LUKS_PARTITIONS = "boot:100M:vfat rootfs:0:ext4"  # 0 = remaining space
LUKS_CIPHER = "aes-xts-plain64"
LUKS_KEY_SIZE = "256"
LUKS_HASH = "sha256"

# Initramfs must include cryptsetup and unlock module
INITRAMFS_IMAGE = "luks-initramfs-image"
PACKAGE_INSTALL:append = " cryptsetup initramfs-module-cryptfs"

Key Design Decision: The rootfs is written unencrypted during the build because BitBake does not run with root privileges (required for cryptsetup luksOpen). Encryption happens post-build via a separate script.

3. Initramfs: The CPUID-Based Unlock Module

The cryptfs module is the heart of our security model. During early boot, it reads the CPU's unique identifier from OTP memory (a 16-byte value at offset 7), combines it with a device-specific salt to derive a passphrase, and attempts to unlock the LUKS volume.

# Core unlock logic - two-stage fallback
OTP_PATH="/sys/bus/nvmem/devices/rockchip-otp0/nvmem"
SALT="device_specific_salt"

# Read CPUID from OTP
cpuid=$(dd if="$OTP_PATH" bs=1 skip=7 count=16 2>/dev/null | hexdump -v -e '16/1 "%02x"')

# Derive passphrase
passphrase="${SALT}:${cpuid}"

# Attempt 1: Try CPUID key (normal boot)
echo -n "$passphrase" | cryptsetup luksOpen /dev/mmcblk0p5 root
if [ $? -eq 0 ]; then
    echo "Unlocked with CPUID key"
    exit 0
fi

# Attempt 2: Try temporary key (first boot only)
echo -n "$TEMP_KEY" | cryptsetup luksOpen /dev/mmcblk0p5 root
if [ $? -eq 0 ]; then
    echo "Unlocked with temporary key - first boot"
    exit 0
fi

# Security fail-safe: power off instead of rescue shell
echo "CRITICAL: All unlock attempts failed"
poweroff -f

If both unlock attempts fail, the system powers off rather than dropping to a rescue shell - a critical security decision that prevents attackers from gaining any system access when the storage is moved to unauthorized hardware.

Critical Design Principles

  • No key storage: Passphrase is derived fresh every boot from hardware-bound data
  • Fallback mechanism: Temporary key enables single-image deployment across multiple devices
  • Security fail-safe: Powers off instead of dropping to shell on failure
  • Simple derivation: Salt + CPUID concatenation (no PBKDF2/Argon2 to keep initramfs minimal and boot fast)

4. First Boot Re-keying Service

A systemd oneshot service orchestrates the hardware binding on first boot. The service first checks if re-keying has already completed via a marker file. If not, it reads the device's CPUID from OTP, derives the hardware-specific passphrase, and adds it to LUKS key slot 1 using the temporary build key for authorization.

#!/bin/bash
# first-boot-rekey.sh - Atomic re-keying operation

MARKER="/etc/.rekey_completed"
ROOTFS_DEV="/dev/mmcblk0p5"

# Skip if already done
[ -f "$MARKER" ] && exit 0

# Read CPUID and derive new passphrase
cpuid=$(dd if=/sys/bus/nvmem/devices/rockchip-otp0/nvmem bs=1 skip=7 count=16 2>/dev/null | hexdump -v -e '16/1 "%02x"')
new_pass="device_specific_salt:${cpuid}"

# Create temporary key files
echo -n "$new_pass" > /tmp/cpuid.key
echo -n "$TEMP_KEY" > /tmp/temp.key

# CRITICAL: Add new key BEFORE removing old key
cryptsetup luksAddKey "$ROOTFS_DEV" /tmp/cpuid.key --key-file=/tmp/temp.key

# Verify new key works independently
echo -n "$new_pass" | cryptsetup luksOpen --test-passphrase "$ROOTFS_DEV"
if [ $? -ne 0 ]; then
    echo "ERROR: CPUID key verification failed!"
    shred -u /tmp/*.key
    exit 1
fi

# Safe to remove temp key now
cryptsetup luksRemoveKey "$ROOTFS_DEV" --key-file=/tmp/temp.key

# Cleanup and mark complete
shred -u /tmp/*.key
echo "rekeyed_at=$(date -Iseconds)" > "$MARKER"

echo "Re-keying complete! Storage now bound to this device."

The critical aspect is the atomic operation sequence: add the new CPUID key, verify it works independently, and only then remove the temporary key. This ensures at least one valid key exists at all times, even if power fails mid-operation. After successful re-keying, sensitive temporary files are wiped and a completion marker prevents the service from running again.

5. Kernel Command Line Configuration

The kernel must know it's booting from an encrypted root. The bootloader passes parameters specifying the root device as a device-mapper target (not the raw partition), the LUKS volume name, and the physical device containing the encrypted data.

# U-Boot/extlinux.conf kernel parameters
root=/dev/mapper/root rootfstype=ext4 rd.luks.name=root rd.luks.uuid=

This informs the kernel to wait for the initramfs to set up device-mapper before attempting to mount the root filesystem. The /dev/mapper/root device doesn't exist until the initramfs successfully unlocks the LUKS volume.

Security Analysis

Threat Model

Threat Mitigation
SD card cloning CPUID mismatch causes boot to fail
Key extraction No keys are stored anywhere
Physical access to device CPUID is in OTP (cannot be read without device-specific access)
Firmware tampering Boot partition is unencrypted, but rootfs is locked to device
Temporary key exposure Removed after first boot, only works until re-keying
LUKS header corruption Catastrophic failure - device bricked (backup recommended)

What We Protect Against

  • [✓] SD card theft: Moving the card to another device causes boot to fail
  • [✓] Data extraction: Rootfs is AES-256 encrypted
  • [✓] Device replacement attacks: New device has different CPUID
  • [✓] Key compromise: There are no stored keys to compromise

What We Do Not Protect Against

  • [✗] Physical device theft with power-on: CPUID can be read if you have physical access
  • [✗] Boot partition tampering: Kernel/initramfs are unencrypted (required for boot)
  • [✗] Cold boot attacks: Theoretical risk if RAM is frozen before key derivation completes
  • [✗] Vendor backdoors: OTP reading depends on vendor's nvmem driver

Technical Challenges and Solutions

Challenge 1: BitBake Runs Without Root

Problem: cryptsetup luksFormat and luksOpen require root privileges, but BitBake's do_image tasks run as a regular user.

Solution:

  1. Build creates unencrypted image with all components
  2. Post-build script (encrypt-image.sh) runs with sudo to encrypt the rootfs partition
  3. Image is then flashed to device
#!/bin/bash
# encrypt-image.sh - Post-build encryption script (requires root)

IMAGE_FILE="console-image-rk3308-luks-gpt.img"
ROOTFS_PARTITION=5  # Partition number for rootfs
TEMP_KEY="build_time_temporary_key"

# Mount the partition via loop device
LOOP_DEV=$(losetup -f --show -P "$IMAGE_FILE")
ROOTFS_DEV="${LOOP_DEV}p${ROOTFS_PARTITION}"

# Format partition with LUKS
echo -n "$TEMP_KEY" | cryptsetup luksFormat \
    --type luks1 \
    --cipher aes-xts-plain64 \
    --key-size 256 \
    --hash sha256 \
    "$ROOTFS_DEV"

# Open the encrypted volume
echo -n "$TEMP_KEY" | cryptsetup luksOpen "$ROOTFS_DEV" cryptroot

# Copy unencrypted rootfs into the encrypted volume
dd if=/path/to/rootfs.ext4 of=/dev/mapper/cryptroot bs=4M

# Cleanup
cryptsetup luksClose cryptroot
losetup -d "$LOOP_DEV"

echo "Image encrypted successfully!"

This separation of concerns keeps the build system unprivileged while allowing encryption as a post-processing step with proper access control.

Challenge 2: CPUID Key Not Available at Build Time

Problem: Each device has a unique CPUID, but we are building a single image for all devices.

Solution: Two-key system:

  1. Temporary key: Added at build time, works on all devices
  2. CPUID key: Added on first boot, unique per device
  3. After first boot, temporary key is removed

This allows:

  • Single image for all devices
  • Automatic hardware binding on first boot
  • No user interaction required

Challenge 3: Initramfs Size Constraints

Problem: Initramfs must be small (loaded into RAM before rootfs is available).

Solution: Minimal design:

  • Simple passphrase derivation (no argon2/pbkdf2)
  • Essential tools only (cryptsetup, dd, hexdump)
  • BusyBox for utilities
  • No Python or complex dependencies

The key insight: we prioritize boot speed and memory footprint over key derivation complexity. Since the CPUID itself is hardware-bound and inaccessible without physical device access, simple concatenation (salt + CPUID) provides sufficient security for our threat model while keeping the initramfs under 32MB.

Challenge 4: Power Loss During Re-keying

Problem: If the device loses power while removing the temporary key, both keys might be invalid.

Solution: We leverage LUKS's multi-slot architecture. The re-keying process adds the CPUID key to a new slot before removing the old key, and crucially verifies the new key works independently. This creates a brief window where both keys are valid, ensuring that power loss at any point leaves at least one working key. The temporary key is only removed after confirming the CPUID key grants access.

This approach embodies the principle of never being in an unrecoverable state - similar to how database transactions work with write-ahead logging.

Performance Considerations

Boot Time Impact

Stage Unencrypted With LUKS Overhead
U-Boot to Kernel ~2s ~2s 0s
Initramfs load ~0.5s ~0.5s 0s
CPUID read N/A ~0.05s +0.05s
LUKS unlock N/A ~0.8s +0.8s
Rootfs mount ~0.3s ~0.3s 0s
Total ~2.8s ~3.65s +0.85s

Conclusion: Less than 1 second boot time overhead for full disk encryption.

Runtime Performance

LUKS uses dm-crypt, which is hardware-accelerated on modern ARM CPUs with Crypto Extensions. The kernel automatically detects and uses AES instruction set extensions when available. With hardware acceleration, the read/write performance impact is only ~5-10% compared to unencrypted storage - a negligible cost for the security gained.

Testing and Validation

Test Cases

We validated the system with comprehensive testing:

First Boot Tests

  • [✓] Fresh encrypted image boots with temporary key
  • [✓] CPUID read correctly from OTP
  • [✓] CPUID key added to LUKS slot 1
  • [✓] Temporary key removed from slot 0
  • [✓] Marker file created

Subsequent Boot Tests

  • [✓] Boots with CPUID key only
  • [✓] Temporary key rejected after re-keying
  • [✓] Re-key service skips (already done)

Security Tests

  • [✓] SD card fails on different device (CPUID mismatch)
  • [✓] No keys stored in filesystem
  • [✓] Boot fails gracefully with corrupted LUKS header

Example of security test - moving SD card to a different device with different CPUID:

# Boot output on unauthorized device (different CPUID):
[    3.452891] initramfs: Reading CPUID from OTP...
[    3.453012] CPUID: a1b2c3d4e5f6789012345678abcdef01  # Different from original
[    3.458923] initramfs: Attempting unlock with CPUID key...
[    3.892451] cryptsetup: Failed to activate device with key file
[    3.892567] initramfs: CPUID unlock failed
[    3.892634] initramfs: Attempting unlock with temporary key...
[    4.127892] cryptsetup: No key available with this passphrase
[    4.128001] initramfs: CRITICAL - All unlock attempts failed
[    4.128112] initramfs: Invalid device or corrupted LUKS header
[    4.128223] System halted.

# Device powers off - no shell access, no data exposure

Edge Cases

  • [✓] Power loss during re-key (at least one key works)
  • [✓] Missing OTP device (graceful error)
  • [✓] Manual LUKS header backup/restore

Validation Strategy

After first boot, validation involves checking these key aspects:

# 1. Read and verify CPUID
dd if=/sys/bus/nvmem/devices/rockchip-otp0/nvmem bs=1 skip=7 count=16 2>/dev/null | hexdump -v -e '16/1 "%02x"'
# Expected: 16-byte hex string unique to this device

# 2. Inspect LUKS key slots
cryptsetup luksDump /dev/mmcblk0p5 | grep "Key Slot"
# Expected output:
# Key Slot 0: DISABLED
# Key Slot 1: ENABLED

# 3. Test CPUID-derived passphrase works
cpuid=$(dd if=/sys/bus/nvmem/devices/rockchip-otp0/nvmem bs=1 skip=7 count=16 2>/dev/null | hexdump -v -e '16/1 "%02x"')
echo -n "device_specific_salt:${cpuid}" | cryptsetup luksOpen --test-passphrase /dev/mmcblk0p5
# Expected: exits with code 0 (success)

# 4. Verify re-keying completion
cat /etc/.rekey_completed
# Expected: rekeyed_at=2026-01-22T10:30:45+00:00

Deployment Workflow

1. Build Phase (Developer Machine)

The Yocto build incorporates the meta-embedded-luks layer, producing an unencrypted GPT image and a LUKS-aware initramfs.

# Add layer to build
bitbake-layers add-layer ../meta-embedded-luks

# Build the image
bitbake console-image-with-luks

# Output: tmp/deploy/images/rk3308/console-image-rk3308-luks-gpt.img

2. Encryption Phase (Host Machine)

A post-build script running with elevated privileges encrypts the rootfs partition in-place using cryptsetup with the temporary key.

# Encrypt the image (requires sudo)
sudo ./scripts/encrypt-image.sh console-image-rk3308-luks-gpt.img

# Flash to SD card
sudo dd if=console-image-rk3308-luks-gpt.img of=/dev/sdX bs=4M status=progress
sync

3. First Boot (Target Device)

The magic happens automatically: initramfs unlocks with the temporary key, the system boots normally, and the re-keying service runs in the background. Within seconds, the temporary key is replaced with the hardware-specific CPUID key.

# Watch the first boot re-keying process
journalctl -u first-boot-rekey -f

# Expected output:
# Jan 22 10:30:45 rk3308 systemd[1]: Starting First Boot Re-keying Service...
# Jan 22 10:30:46 rk3308 first-boot-rekey.sh[342]: Reading CPUID from OTP...
# Jan 22 10:30:46 rk3308 first-boot-rekey.sh[342]: Adding CPUID key to slot 1...
# Jan 22 10:30:47 rk3308 first-boot-rekey.sh[342]: Removing temporary key...
# Jan 22 10:30:47 rk3308 first-boot-rekey.sh[342]: Re-keying complete!

4. Verification

Post-deployment verification checks systemd logs for successful re-keying and inspects the LUKS header to confirm slot 0 is disabled and slot 1 is active.

# Check re-keying status
cat /etc/.rekey_completed
# Output: rekeyed_at=2026-01-22T10:30:47+00:00

# Verify LUKS slots
cryptsetup luksDump /dev/mmcblk0p5 | grep -A2 "Key Slot"
# Output:
# Key Slot 0: DISABLED
# Key Slot 1: ENABLED (shows last modification time)

Platform-Specific Details: Rockchip SoCs

Our implementation targets Rockchip RK3308 SoCs, but the approach is portable to any ARM SoC with OTP.

Rockchip OTP Structure

Offset Length Content
0x00 4 Chip version
0x04 3 Reserved
0x07 16 CPU ID (unique per chip) - We use this
0x17 16 LCS (Life Cycle State)

The CPUID is burned during manufacturing and cannot be changed.

nvmem Driver

Linux exposes OTP via the nvmem subsystem, providing a standardized interface to read fused data. The nvmem device appears under /sys/bus/nvmem/devices/ and can be accessed as a binary file, making it straightforward to extract the CPUID from user space or during early boot.

Porting to Other Platforms

To adapt this to other ARM SoCs:

  1. Identify OTP location: Most ARM SoCs have fuse banks
    • i.MX: /sys/fsl_otp/HW_OCOTP_*
    • STM32: /sys/bus/nvmem/devices/stm32-romem0
    • Allwinner: /sys/bus/nvmem/devices/sunxi-sid0
  2. Update OTP_PATH: Change in cryptfs and first-boot-rekey.sh
  3. Adjust offset/length: Match your SoC's CPUID location
  4. Test: Verify uniqueness across multiple devices

Future Enhancements

For production deployments, consider these additional security measures: automatic LUKS header backups to the boot partition, adding a recovery key to a separate slot for disaster recovery, enabling kernel audit logging to track system access, and implementing remote attestation to verify device identity by sending a hash of the CPUID to a central server during first boot.

Conclusion

We have built a robust, hardware-bound storage encryption system that:

  • [✓] Requires no stored keys
  • [✓] Binds storage to specific hardware
  • [✓] Works with Yocto/BitBake builds
  • [✓] Adds minimal boot time overhead (<1s)
  • [✓] Survives power loss during re-keying
  • [✓] Provides strong security for embedded devices

The key insight is the two-key approach: a temporary build-time key allows single-image deployment, while first-boot re-keying creates per-device hardware binding.

Use Cases

This approach is ideal for:

  • Edge computing devices: Prevent data theft if device is stolen
  • Industrial IoT: Ensure firmware/config tied to specific hardware
  • Medical devices: Protect patient data on embedded systems
  • Automotive: Secure in-vehicle infotainment systems

Further Reading

About the Implementation: This system has been tested on Rockchip RK3308-based ARM devices running Yocto 3.1 (Dunfell) and 3.4 (Kirkstone). The approach is generic and portable to other ARM platforms with OTP support.

Tags

LUKS Embedded-Linux Storage-Encryption Hardware-Security Yocto ARM CPUID OTP Device-Binding
⌘K to search