SecureLink Command Console

Secure a Pico 2 command console with HMAC, replay protection, and attack tests.

Introduction

30 Second Summary

Physical controls feel trustworthy because you can see the device respond. However, a cable connection becomes a security risk when any typed command can change that device without proof of who sent it.

In this project, you will turn a Raspberry Pi Pico 2 into a USB-controlled security node for authenticated ARM and DISARM commands. You will implement HMAC-SHA-256 command authentication plus sequence-based replay protection in MicroPython.

What You'll Build

Submit a valid ARM packet through Thonny to light the Pico 2's onboard LED, then run the device through an attack sequence that visibly rejects tampering and replay.

By the end of this project, you'll have:

  • Demonstrated the insecure console by entering DISARM without credentials and watching the onboard LED turn off.
  • Sent an authenticated command that changes the physical state only after its HMAC-SHA-256 tag and sequence number pass verification.
  • Run a repeatable attack demo that passes an RFC 4231 test vector while rejecting tampered and replayed packets.
  • Secret Mission: Add a three-strike lockout that blocks commands after three consecutive invalid packets.

Are there any prerequisites?

You'll need a Windows PC with permission to install software. The required hardware is a Raspberry Pi Pico 2 plus a Micro-USB data cable that supports data.

No prior embedded systems or cybersecurity experience is required.

Before We Start

This checkpoint gives you a moment to lock in the security problem your finished device will prove. You are building an embedded command console that rejects forged and replayed commands to demonstrate your firmware and cybersecurity skills.

Set Up the Pico 2 Security Lab

SecureLink needs a dependable path from your Windows PC to the physical board. An unreliable embedded firmware setup would hide whether later security controls changed the device state.

You will prepare a Raspberry Pi Pico 2. You will install Thonny 5.0.0 on Windows.

You will flash MicroPython 1.29.0. The finished setup lets the Shell control the onboard LED.

In this step, get ready to:
  • Get the Pico 2 hardware and install the correct Thonny version.
  • Flash the Pico 2 with the correct MicroPython firmware.
  • Connect the Thonny Shell and prove the onboard LED responds.
Get the hardware and install Thonny

This is the project's only hardware purchase. The board's official list price is US$5.00.

The project uses the Pico 2's onboard LED. Headers are unnecessary.

A data-capable Micro-USB cable supplies power. The same cable carries device data.

  • Obtain a Raspberry Pi Pico 2.
  • Use a Micro-USB cable that supports power plus data.
  • Keep the Pico 2 disconnected while you install Thonny.

Thonny provides the editor and interactive Shell for this project. Its Windows installer must match your processor architecture.

  • Click Start on the Windows taskbar.
  • Select Settings.
  • Select System.
  • Select About.
  • Find System type under Device specifications.

You should see a value containing either x64-based processor or ARM64-based processor. That value identifies the installer you need.

✔️ I already have the current Thonny

Your installed version is Thonny 5.0.0. Open it so it is ready for the Pico 2.

  • Press the Windows key to open search.
  • Type Thonny.
  • Press Enter to open Thonny.

You should see the Thonny editor window.

ⓧ I have an older Thonny version

Install Thonny 5.0.0 over the older release. Windows may request permission because the installer adds an application.

  • Download thonny-5.0.0-x64.exe from the official Thonny 5.0.0 release page if your PC shows x64-based processor.
  • Download thonny-5.0.0-arm64.exe from the official Thonny download page if your PC shows ARM64-based processor.
  • Close the older Thonny window.
  • Run the downloaded installer.
  • Complete each installer prompt using the default options.
  • Press the Windows key to open search.
  • Type Thonny.
  • Press Enter to open Thonny.

You should see the Thonny editor window.

ⓧ Thonny is not installed

Install Thonny 5.0.0 for your processor architecture. Windows may request permission because the installer adds an application.

  • Download thonny-5.0.0-x64.exe from the official Thonny 5.0.0 release page if your PC shows x64-based processor.
  • Download thonny-5.0.0-arm64.exe from the official Thonny download page if your PC shows ARM64-based processor.
  • Run the downloaded installer.
  • Complete each installer prompt using the default options.
  • Press the Windows key to open search.
  • Type Thonny.
  • Press Enter to open Thonny.

You should see the Thonny editor window.

Your editor is ready. Thonny now provides the Windows side of the connection to your Pico 2.

Installer Will Not Open?

  • Confirm the installer filename matches the processor shown under System type.
  • Approve the Windows permission request if one appears.
  • Use help me choose or install the correct Thonny installer for guided troubleshooting.
  • Share your Windows processor architecture in the NextWork community if the installer still does not open.
Flash the Pico 2 firmware

A UF2 file packages firmware for drag-and-drop installation. The Pico 2 exposes a temporary storage device when it starts in BOOTSEL mode.

✔️ I have the current Pico 2 UF2

Your downloaded file is the RPI_PICO2 release for MicroPython 1.29.0.

  • Keep the downloaded .uf2 file ready for the board.

ⓧ I have a different UF2

A different board target or release does not match this project's setup. Download the pinned Pico 2 firmware.

ⓧ I have no UF2 yet

Download the MicroPython release made for the Pico 2 before entering BOOTSEL mode.

  • Disconnect the Pico 2 from your PC.
  • Hold the BOOTSEL button on the Pico 2.
  • Reconnect the Micro-USB cable directly to your PC while holding BOOTSEL.
  • Release BOOTSEL when the RP2350 storage device appears.

What Is BOOTSEL Mode?

BOOTSEL mode exposes the Pico 2 as a USB storage device named RP2350. Copying a compatible UF2 file to that device programs the board's flash memory.

  • Open the Windows file browser.
  • Locate the downloaded .uf2 file.
  • Copy the .uf2 file.
  • Open the RP2350 storage device.
  • Paste the .uf2 file onto RP2350.

The Pico 2 automatically restarts into MicroPython after programming completes. The temporary RP2350 storage device disconnects.

That is the firmware layer in place. Your board can now accept MicroPython instructions from Thonny.

RP2350 Does Not Appear?

Reconnect the Pico 2 while holding BOOTSEL before the cable supplies power. Connect the cable directly to the PC instead of using an unpowered hub.

A charge-only cable can power the board without carrying data. Replace it with a data-capable Micro-USB cable if Windows never detects the board.

Use help me get my Pico 2 into BOOTSEL mode for guided troubleshooting.

Connect Thonny and prove physical output

The USB serial REPL is MicroPython's interactive Shell on the board. Each line you enter runs immediately on the Pico 2.

  • Switch back to Thonny from earlier.
  • Click Run in the top menu.
  • Choose Select interpreter.
  • Select MicroPython (Raspberry Pi Pico).
  • Select the Pico 2's COM port if Thonny requests one.

The Thonny Shell should now connect to the Pico 2. This confirms that Windows can communicate with the MicroPython interpreter.

Pico 2 Missing From the Port List?

Confirm the board restarted after the UF2 copy. Reconnect the data-capable cable directly to your PC if the port list remains empty.

Use help me connect Thonny to my Pico 2 COM port for guided troubleshooting.

Before you run this check, how long do you expect the onboard LED to stay on?

  • Click inside the Thonny Shell.
  • Run the physical output check by entering the code below:
from machine import Pin
import time
led = Pin("LED", Pin.OUT)
led.on()
time.sleep(1)
led.off()

What Does This Code Do?

  • The Pin import gives MicroPython access to the Pico 2's digital pins.
  • The led variable targets the onboard LED as an output.
  • The led.on() call turns the LED on.
  • The time.sleep(1) call holds that state for one second.
  • The led.off() call turns the LED off.
  • Watch the Pico 2's onboard LED while the code runs.
  • Confirm the LED stays on for one second before turning off.

You have the full hardware path working. Thonny can now run MicroPython code that changes a physical output on your Pico 2.

No LED Pulse?

Confirm Thonny still shows MicroPython (Raspberry Pi Pico) with the Pico 2's COM port. A different interpreter runs the code away from the board.

Reconnect the data-capable Micro-USB cable if the Shell loses its prompt. Run the six lines again after the Shell reconnects.

Use help me diagnose why my Pico 2 onboard LED does not pulse for guided troubleshooting.

Your Pico 2 security lab is ready. Next up, you will expose why a command console without authentication gives anyone with transport access control of the device.

Expose the Unauthenticated Command Flaw

Your Raspberry Pi Pico 2 has already proved that MicroPython can drive its onboard LED. That visible pulse confirms the USB serial REPL can control physical state.

This step gives plain text from the Shell a direct path to the LED. You will test whether someone with console access can change the device state without proving who created the command.

In this step, get ready to:
  • Build an intentionally naive controller for ARM and DISARM commands.
  • Test the controller with an unauthenticated DISARM command.
  • Capture the Shell result as evidence of the flaw.
Build the naive controller

The baseline controller uses a plain-text command as its entire decision input. A recognized word changes the protected state immediately.

  • Press Ctrl+N in Thonny to create a blank editor file.
  • Build the insecure controller by pasting this code into the blank editor:
from machine import Pin

led = Pin("LED", Pin.OUT)
state = "ARMED"
led.on()

while True:
    command = input("command> ").strip()

    if command == "ARM":
        state = "ARMED"
        led.on()
        print("INSECURE ACCEPT:", command)
    elif command == "DISARM":
        state = "DISARMED"
        led.off()
        print("INSECURE ACCEPT:", command)
    else:
        print("REJECT unsupported command")

What does this code do?

  • The Pin("LED", Pin.OUT) expression configures the onboard LED as a digital output.
  • The state variable begins as ARMED. The following call turns the LED on.
  • The input() call waits for command text from the Shell.
  • The two recognized command branches update the state. Each branch changes the LED immediately.
  • The final branch rejects any command outside ARM or DISARM.
  • Click File in the Thonny menu bar.
  • Click Save as.

You will see a destination chooser for the new file.

  • Select the connected Pico 2 device as the save destination.
  • Enter main.py as the filename.

The save dialog now shows main.py for the connected device.

  • Confirm the save.

The editor tab now identifies the file as main.py. The controller is stored on the Pico 2.

  • Run the saved controller by pressing F5.

The Shell displays command>. The onboard LED turns on because the controller starts in the ARMED state.

You have a live physical controller now. Every command typed at the prompt can reach its decision logic.

No command prompt?

  • Confirm the lower-right backend still shows MicroPython (Raspberry Pi Pico).
  • Save main.py to the connected device again if the file has not been named.
  • Use Thonny's stop control if another script still owns the Shell.

Ask for help with the exact behavior you see: help me get the command prompt from main.py on my Pico 2

✔️ Awesome, I've got everything!

Great. Your Pico 2 now has the complete insecure controller in main.py.

ⓧ I'd like to double check the full code

  • Compare your device copy of main.py with this complete file:
from machine import Pin

led = Pin("LED", Pin.OUT)
state = "ARMED"
led.on()

while True:
    command = input("command> ").strip()

    if command == "ARM":
        state = "ARMED"
        led.on()
        print("INSECURE ACCEPT:", command)
    elif command == "DISARM":
        state = "DISARMED"
        led.off()
        print("INSECURE ACCEPT:", command)
    else:
        print("REJECT unsupported command")
Perform the unauthorized command test

A protected device should change state only for trusted instructions. This test asks whether the controller enforces that rule.

Before you type it, do you expect this command to change the protected state without any proof of authority?

  • Submit an unauthenticated command by typing the following at the command> prompt and pressing Enter:
DISARM

What just happened?

  • The input() call accepts the word without requesting a credential.
  • The DISARM branch sets state to DISARMED.
  • The led.off() call changes the physical output before any identity check.
  • The controller never requests a key. It never checks a tag or sequence number.

You will see INSECURE ACCEPT: DISARM in the Shell. The onboard LED turns off.

That result is the designed failure for this step. Console access alone currently grants control of the protected state.

No insecure acceptance message?

  • Click inside the Shell after the command> prompt before typing the command.
  • Enter DISARM with uppercase letters if you see REJECT unsupported command.
  • Run main.py again if the Shell no longer shows the command prompt.

Get help with the observed result: help me troubleshoot the unauthorized DISARM test

You have captured the exact failure this security demo needs. The physical state changed without a credential or integrity check.

Your attack proof is captured. Next, you will require commands to prove their origin before the LED changes.

Authenticate Commands with HMAC-SHA-256

Your Raspberry Pi Pico 2 just accepted DISARM from anyone with console access. That result proves the device changes protected state without checking who created the command.

You'll rebuild the controller as three files on the device through Thonny. HMAC-SHA-256 authenticates each packet before the LED changes.

A constant-time comparison checks the supplied tag. A sequence number provides replay protection for accepted packets.

In this step, get ready to:
  • Store a learning-only shared key in its own file.
  • Build authenticated packets with tag comparison and sequence checks.
  • Connect valid commands to the onboard LED.
Add the shared demonstration key

The shared key lets the sender create an authentication tag that the processor can verify. This fixed value is intentionally visible, so keep it confined to this learning project.

  • Create a blank editor tab in Thonny.
  • Add the learning-only key by pasting this code:
# Learning-only shared key. Do not reuse this value in a real product.
SHARED_KEY = b"replace-with-32-byte-demo-key!!!"

What does this key file do?

  • The comment records the boundary between this demonstration and a production key-management process.
  • The SHARED_KEY value gives both packet creation and packet verification the same secret bytes.
  • Use the device-save workflow from earlier to save the file to the Pico 2 as project_key.py.
  • Confirm the device file pane lists project_key.py.

Don't see project_key.py?

Check that you saved the file to the connected Pico 2 instead of the Windows computer. Confirm the filename uses the underscore in project_key.py.

Make sure the Pico 2 interpreter from the previous step remains selected in Thonny.

Help me save project_key.py to my connected Pico 2 in Thonny.

✔️ Awesome, I've got everything!

Your Pico 2 now stores the learning-only shared key in project_key.py.

ⓧ I'd like to double check the full code

Compare your device copy of project_key.py with this complete file.

# Learning-only shared key. Do not reuse this value in a real product.
SHARED_KEY = b"replace-with-32-byte-demo-key!!!"
Implement authenticated packets

An HMAC combines the shared key with the packet message through inner and outer hash stages. The resulting tag changes when the sequence number or command changes.

Why implement HMAC directly?

Building the RFC 2104 construction exposes the key padding and two hashing stages behind HMAC. That makes each security decision visible in a small firmware project.

Production hardware should use an audited cryptographic library or a secure element. This implementation stays focused on learning and later validation against a published test vector.

  • Create another blank editor tab in Thonny.
  • Add the module foundation and HMAC function by pasting this code:
import binascii
import hashlib

BLOCK_SIZE = 64
ALLOWED_COMMANDS = ("ARM", "DISARM")


def hmac_sha256(key, message):
    if len(key) > BLOCK_SIZE:
        key = hashlib.sha256(key).digest()

    padded_key = key + (b"\x00" * (BLOCK_SIZE - len(key)))
    inner_pad = bytearray(BLOCK_SIZE)
    outer_pad = bytearray(BLOCK_SIZE)

    for index in range(BLOCK_SIZE):
        inner_pad[index] = padded_key[index] ^ 0x36
        outer_pad[index] = padded_key[index] ^ 0x5C

    inner_digest = hashlib.sha256(bytes(inner_pad) + message).digest()
    return hashlib.sha256(bytes(outer_pad) + inner_digest).digest()

How does the HMAC function work?

  • The imports provide SHA-256 hashing plus hexadecimal conversion for later packet handling.
  • BLOCK_SIZE sets the SHA-256 HMAC block size to 64 bytes.
  • The padded key is combined with the inner and outer pad values before each hash stage.
  • hmac_sha256() returns the final authentication tag as bytes.
  • Save this first module section to the Pico 2 as secure_channel.py.
  • Confirm the device file pane lists secure_channel.py.

Can't save secure_channel.py?

Confirm the Pico 2 is still connected in Thonny. Save the file to the device with the exact name secure_channel.py.

Help me save secure_channel.py to my Pico 2 from Thonny.

Packets need a consistent representation before they can be authenticated. The next helpers encode sequence|command and attach a hexadecimal tag.

  • Place your cursor two blank lines below hmac_sha256() in secure_channel.py.
  • Add the packet-building helpers by pasting this code:
def tag_to_hex(tag):
    return binascii.hexlify(tag).decode("ascii")


def message_bytes(sequence, command):
    return "{}|{}".format(sequence, command).encode("ascii")


def build_packet(key, sequence, command):
    tag = hmac_sha256(key, message_bytes(sequence, command))
    return "{}|{}|{}".format(sequence, command, tag_to_hex(tag))

What do the packet helpers do?

  • tag_to_hex() turns tag bytes into text that can travel through the console.
  • message_bytes() gives the sequence number and command one consistent authenticated format.
  • build_packet() calculates the tag before joining all three packet fields.
  • Save secure_channel.py to the Pico 2.
  • Confirm build_packet() appears below message_bytes() in the saved file.

Don't see the packet helpers?

Check that the new functions start at the left edge of the editor. Keep two blank lines between each top-level function.

Help me place the packet helper functions correctly in secure_channel.py.

A normal comparison can stop as soon as it finds a different byte. This comparison scans every paired byte before returning its result.

  • Place your cursor two blank lines below build_packet().
  • Add the tag comparison function by pasting this code:
def constant_time_equal(left, right):
    if len(left) != len(right):
        return False

    mismatch = 0
    for left_byte, right_byte in zip(left, right):
        mismatch |= left_byte ^ right_byte
    return mismatch == 0

How does the comparison work?

  • Different lengths fail before byte comparison because equal tags must have equal lengths.
  • mismatch collects every byte difference through a bitwise operation.
  • The function returns True only when no differences were collected.
  • Save secure_channel.py to the Pico 2.
  • Confirm the saved function ends with return mismatch == 0.

Is the comparison indented incorrectly?

Keep the length check and mismatch loop inside constant_time_equal(). Make sure the final return aligns with mismatch = 0.

Help me fix the indentation in constant_time_equal().

The processor owns the current protected state and the highest accepted sequence number. Its first task is to split incoming text into the three required fields.

  • Place your cursor two blank lines below constant_time_equal().
  • Add the processor state and packet parsing by pasting this code:
class SecureCommandProcessor:
    def __init__(self, key, led):
        self.key = key
        self.led = led
        self.last_sequence = 0
        self.state = "DISARMED"
        self.led.off()

    def process(self, packet):
        parts = packet.strip().split("|")
        if len(parts) != 3:
            return "REJECT invalid format"

        sequence_text, command, supplied_tag_text = parts

        try:
            sequence = int(sequence_text)
            supplied_tag = binascii.unhexlify(supplied_tag_text)
        except Exception:
            return "REJECT invalid encoding"

What does the processor store?

  • key and led connect packet verification to the protected hardware output.
  • last_sequence begins at 0 so the first positive sequence can be accepted.
  • state begins as DISARMED while the onboard LED is off.
  • process() rejects packets that cannot be split or decoded correctly.
  • Save secure_channel.py to the Pico 2.
  • Confirm the saved class includes both __init__() and process().

Is the class structure broken?

Keep both methods indented inside SecureCommandProcessor. Keep the parsing logic indented inside process().

Help me correct the SecureCommandProcessor indentation in secure_channel.py.

A decoded packet still needs policy checks before it can reach the LED. The processor rejects invalid sequences, unsupported commands, incorrect tags, and reused sequences.

  • Place your cursor one blank line below return "REJECT invalid encoding" inside process().
  • Add the authentication and replay checks by pasting this code:
        if sequence <= 0:
            return "REJECT invalid sequence"

        if command not in ALLOWED_COMMANDS:
            return "REJECT unsupported command"

        expected_tag = hmac_sha256(
            self.key,
            message_bytes(sequence, command),
        )

        if not constant_time_equal(supplied_tag, expected_tag):
            return "REJECT bad MAC"

        if sequence <= self.last_sequence:
            return "REJECT replayed sequence"

What gets rejected here?

  • Non-positive sequence numbers fail before authentication can affect state.
  • Commands outside ALLOWED_COMMANDS cannot reach the LED controls.
  • The expected tag is rebuilt from the received sequence number and command.
  • Previously accepted sequence numbers fail the replay check.
  • Save secure_channel.py to the Pico 2.
  • Confirm the replay check is the final visible condition in process().

Did the checks land outside process()?

Every line in this chunk starts inside process(). Align the first if with the earlier try block.

Help me place the authentication checks inside process().

Only a packet that passes every check reaches the state transition. The accepted sequence becomes the new replay boundary before the LED changes.

  • Place your cursor one blank line below return "REJECT replayed sequence".
  • Complete the accepted-command path by pasting this code:
        self.last_sequence = sequence
        self.state = command

        if command == "ARM":
            self.led.on()
        else:
            self.led.off()

        return "ACCEPT sequence={} command={}".format(sequence, command)

How does an accepted packet change state?

  • last_sequence records the accepted sequence for the next replay check.
  • state records the authenticated command.
  • ARM turns the LED on.
  • DISARM turns the LED off.
  • Save the completed secure_channel.py to the Pico 2.
  • Confirm the final line returns an ACCEPT message containing the sequence and command.

Seeing a syntax error in secure_channel.py?

Check the indentation beneath the final if and else blocks. Make sure the final return stays inside process().

Compare every quote and parenthesis against the complete file below.

Help me find a syntax error in secure_channel.py.

✔️ Awesome, I've got everything!

Your completed secure_channel.py now builds packets and verifies them before changing device state.

ⓧ I'd like to double check the full code

Compare your device copy of secure_channel.py with this complete file.

import binascii
import hashlib

BLOCK_SIZE = 64
ALLOWED_COMMANDS = ("ARM", "DISARM")


def hmac_sha256(key, message):
    if len(key) > BLOCK_SIZE:
        key = hashlib.sha256(key).digest()

    padded_key = key + (b"\x00" * (BLOCK_SIZE - len(key)))
    inner_pad = bytearray(BLOCK_SIZE)
    outer_pad = bytearray(BLOCK_SIZE)

    for index in range(BLOCK_SIZE):
        inner_pad[index] = padded_key[index] ^ 0x36
        outer_pad[index] = padded_key[index] ^ 0x5C

    inner_digest = hashlib.sha256(bytes(inner_pad) + message).digest()
    return hashlib.sha256(bytes(outer_pad) + inner_digest).digest()


def tag_to_hex(tag):
    return binascii.hexlify(tag).decode("ascii")


def message_bytes(sequence, command):
    return "{}|{}".format(sequence, command).encode("ascii")


def build_packet(key, sequence, command):
    tag = hmac_sha256(key, message_bytes(sequence, command))
    return "{}|{}|{}".format(sequence, command, tag_to_hex(tag))


def constant_time_equal(left, right):
    if len(left) != len(right):
        return False

    mismatch = 0
    for left_byte, right_byte in zip(left, right):
        mismatch |= left_byte ^ right_byte
    return mismatch == 0


class SecureCommandProcessor:
    def __init__(self, key, led):
        self.key = key
        self.led = led
        self.last_sequence = 0
        self.state = "DISARMED"
        self.led.off()

    def process(self, packet):
        parts = packet.strip().split("|")
        if len(parts) != 3:
            return "REJECT invalid format"

        sequence_text, command, supplied_tag_text = parts

        try:
            sequence = int(sequence_text)
            supplied_tag = binascii.unhexlify(supplied_tag_text)
        except Exception:
            return "REJECT invalid encoding"

        if sequence <= 0:
            return "REJECT invalid sequence"

        if command not in ALLOWED_COMMANDS:
            return "REJECT unsupported command"

        expected_tag = hmac_sha256(
            self.key,
            message_bytes(sequence, command),
        )

        if not constant_time_equal(supplied_tag, expected_tag):
            return "REJECT bad MAC"

        if sequence <= self.last_sequence:
            return "REJECT replayed sequence"

        self.last_sequence = sequence
        self.state = command

        if command == "ARM":
            self.led.on()
        else:
            self.led.off()

        return "ACCEPT sequence={} command={}".format(sequence, command)
Connect authentication to the LED

The insecure loop changed the LED as soon as it recognized a command. The new main.py delegates that decision to SecureCommandProcessor.

  • Switch back to main.py from the previous step.
  • Replace the insecure controller with this authenticated loop:
from machine import Pin

from project_key import SHARED_KEY
from secure_channel import SecureCommandProcessor, build_packet

led = Pin("LED", Pin.OUT)
processor = SecureCommandProcessor(SHARED_KEY, led)

print("SecureLink ready")
print("Packet format: sequence|command|tag")
print("Example valid packet:", build_packet(SHARED_KEY, 1, "ARM"))

while True:
    packet = input("packet> ")
    print(processor.process(packet))
    print("STATE:", processor.state)

What does the secure command loop do?

  • The imports connect the learning key and packet processor to the onboard LED.
  • SecureCommandProcessor starts in the DISARMED state with the LED off.
  • build_packet() prints one valid sequence 1 packet for the first test.
  • The loop sends each packet to process() before printing the protected state.

✔️ Awesome, I've got everything!

Your new main.py is ready to test with the other two device files.

ⓧ I'd like to double check the full code

Compare your device copy of main.py with this complete file.

from machine import Pin

from project_key import SHARED_KEY
from secure_channel import SecureCommandProcessor, build_packet

led = Pin("LED", Pin.OUT)
processor = SecureCommandProcessor(SHARED_KEY, led)

print("SecureLink ready")
print("Packet format: sequence|command|tag")
print("Example valid packet:", build_packet(SHARED_KEY, 1, "ARM"))

while True:
    packet = input("packet> ")
    print(processor.process(packet))
    print("STATE:", processor.state)
  • Save the replacement main.py to the Pico 2.

Before you run the secure controller, what do you expect the Shell and LED to do when the printed packet passes every check?

  • Click Run in Thonny.
  • Record the packet printed after Example valid packet: as your printed valid packet.
  • Paste your printed valid packet into the packet> prompt.
  • Press Enter to submit the packet.

You'll see ACCEPT sequence=1 command=ARM followed by STATE: ARM. The onboard LED turns on.

That closes the original security gap. Your Pico 2 now changes protected state only after a packet passes authentication and replay checks.

Was the valid packet rejected?

Copy only the packet text after Example valid packet:. Include the full hexadecimal tag.

Confirm project_key.py, secure_channel.py, and main.py all appear on the Pico 2 with those exact names.

Help me diagnose why my printed valid packet is rejected by SecureLink.

Your authenticated console now accepts a valid packet and lights the onboard LED. Next, you'll test the same firmware against tampering and replay attacks.

Prove the Firmware Rejects Attacks

Your Raspberry Pi Pico 2 now accepts authenticated packets through HMAC-SHA-256. A security control earns trust when repeatable evidence covers allowed traffic.

This step adds evidence for hostile traffic too. The test harness checks your implementation against RFC 4231 before submitting valid packets and attack packets.

In this step, get ready to:
  • Check HMAC-SHA-256 against RFC 4231 test case 1.
  • Run valid, tampered, replayed, and follow-up packet tests.
  • Capture the results and document the prototype boundaries.
Add the RFC 4231 self-test

A published test vector supplies known inputs. The resulting digest gives your implementation an independent standard to match.

  • Stop the running main.py command loop using Thonny's stop control.
  • Create a blank editor tab in Thonny.
  • Build the standards check by copying this code into the blank tab:
import binascii
from machine import Pin

from project_key import SHARED_KEY
from secure_channel import (
    SecureCommandProcessor,
    build_packet,
    hmac_sha256,
    tag_to_hex,
)

RFC_KEY = bytes([0x0B] * 20)
RFC_MESSAGE = b"Hi There"
RFC_EXPECTED = (
    "b0344c61d8db38535ca8afceaf0bf12b"
    "881dc200c9833da726e9376c2e32cff7"
)

actual = tag_to_hex(hmac_sha256(RFC_KEY, RFC_MESSAGE))
print("RFC 4231 self-test:", "PASS" if actual == RFC_EXPECTED else "FAIL")

How does the self-test work?

  • The imports reuse the same HMAC function that authenticates your device commands.
  • The RFC_KEY value contains the test vector's repeated key byte.
  • The RFC_EXPECTED value stores the published HMAC-SHA-256 digest.
  • The comparison prints PASS only when your implementation produces the exact digest.
  • Click File in Thonny's menu.
  • Choose Save as to save the tab to the connected MicroPython device as attack_demo.py.

Before you run the self-test, do you expect your HMAC implementation to match the published digest?

  • Run attack_demo.py by clicking Run in Thonny.

The Shell prints RFC 4231 self-test: PASS. You have your first independent proof.

Your HMAC implementation matches the published test vector.

Self-test showing FAIL?

Compare RFC_KEY, RFC_MESSAGE, and RFC_EXPECTED character for character against the code above.

Confirm that attack_demo.py is saved on the connected device. Make sure its editor tab is active before clicking Run.

help me diagnose why the RFC 4231 HMAC-SHA-256 self-test prints FAIL on my Pico 2

Exercise the attack paths

One passing packet proves the valid path works. Controlled packet changes reveal whether authentication and replay protection hold under attack.

  • Place your cursor at the end of attack_demo.py.
  • Add the attack sequence by copying this code below the self-test:
led = Pin("LED", Pin.OUT)
processor = SecureCommandProcessor(SHARED_KEY, led)

valid_arm = build_packet(SHARED_KEY, 1, "ARM")
tampered = valid_arm.replace("|ARM|", "|DISARM|")
valid_disarm = build_packet(SHARED_KEY, 2, "DISARM")

print("VALID:", processor.process(valid_arm))
print("TAMPERED:", processor.process(tampered))
print("REPLAY:", processor.process(valid_arm))
print("VALID NEXT:", processor.process(valid_disarm))
print("Final state:", processor.state)

What does the attack sequence test?

  • The valid_arm packet carries an authenticated sequence number of 1.
  • The tampered packet changes the command text while keeping the original tag.
  • The second submission of valid_arm presents a sequence number that the processor has already accepted.
  • The valid_disarm packet uses sequence number 2 to test fresh authenticated traffic.
  • Save attack_demo.py to the connected MicroPython device.

Before you run the full demo, which packets do you expect the processor to accept?

  • Run the complete attack_demo.py by clicking Run in Thonny.

The Shell prints RFC 4231 self-test: PASS. It then prints VALID: ACCEPT sequence=1 command=ARM.

The tampering test prints TAMPERED: REJECT bad MAC. The replay test prints REPLAY: REJECT replayed sequence.

The fresh command prints VALID NEXT: ACCEPT sequence=2 command=DISARM. The final line reads Final state: DISARMED.

The onboard LED finishes off. That is the complete security story in one repeatable run.

Results don't match?

If the tampered packet is accepted, confirm that tampered replaces |ARM| with |DISARM| without rebuilding the tag.

If the replay packet is accepted, confirm that the replay line submits valid_arm again after its first successful submission.

help me compare my attack_demo.py results with the expected valid, tampered, replayed, and valid-next outcomes

✔️ Awesome, I've got everything!

Your final attack_demo.py is saved on the Pico 2.

ⓧ I'd like to double check the full code

Compare your device copy of attack_demo.py with this complete file.

import binascii
from machine import Pin

from project_key import SHARED_KEY
from secure_channel import (
    SecureCommandProcessor,
    build_packet,
    hmac_sha256,
    tag_to_hex,
)

RFC_KEY = bytes([0x0B] * 20)
RFC_MESSAGE = b"Hi There"
RFC_EXPECTED = (
    "b0344c61d8db38535ca8afceaf0bf12b"
    "881dc200c9833da726e9376c2e32cff7"
)

actual = tag_to_hex(hmac_sha256(RFC_KEY, RFC_MESSAGE))
print("RFC 4231 self-test:", "PASS" if actual == RFC_EXPECTED else "FAIL")

led = Pin("LED", Pin.OUT)
processor = SecureCommandProcessor(SHARED_KEY, led)

valid_arm = build_packet(SHARED_KEY, 1, "ARM")
tampered = valid_arm.replace("|ARM|", "|DISARM|")
valid_disarm = build_packet(SHARED_KEY, 2, "DISARM")

print("VALID:", processor.process(valid_arm))
print("TAMPERED:", processor.process(tampered))
print("REPLAY:", processor.process(valid_arm))
print("VALID NEXT:", processor.process(valid_disarm))
print("Final state:", processor.state)
Capture portfolio evidence

A threat model defines what an attacker can do. A prototype boundary records which security decisions still need production-grade treatment.

  • Capture the complete Thonny Shell output in one screenshot.
  • Add a threat model section to your repository's README.
  • State that an attacker can observe or inject console packets.
  • State that the attacker does not know SHARED_KEY.
  • Add a prototype boundary note to the README.
  • State that production hardware needs secure provisioning.
  • State that production hardware needs protected key storage.

Your evidence now connects a standards-based self-test to visible attack rejections. The demo shows exactly which packets can change the protected state.

Secret mission

Add a Three-Strike Lockout

Extend SecureLink with a three-strike lockout for repeated invalid packets. After the third consecutive rejection, the processor blocks every later command. An automated demo proves that even a valid packet cannot change the protected state after lockout.

Clean Up Your Resources

Clean Up Your Resources

SecureLink runs entirely on your Raspberry Pi Pico 2, so there are no cloud resources or ongoing service fees. Choose whether to keep the board connected, pause it, or delete the five project files from its storage.

Resources you used:

  • Your Raspberry Pi Pico 2 connection to Thonny through the Micro-USB data cable.
  • The five project files stored on the Pico 2: project_key.py, secure_channel.py, main.py, attack_demo.py, and lockout_demo.py.

Keep everything running

No action needed. Choose this if you want to rerun the attack demonstrations or keep improving the firmware.

  • Keep the Pico 2 connected to Thonny through the Micro-USB data cable.
  • Keep project_key.py, secure_channel.py, main.py, attack_demo.py, and lockout_demo.py on the Pico 2.

The current lockout state exists only in RAM. A restart clears that state while preserving the project files.

Pause - I'll come back to this later

Pausing removes power from the Pico 2. The project files stay on the board for your next session.

  • Stop any running script with Thonny's stop control in the top toolbar.
  • Disconnect the Micro-USB cable from the Windows PC.

The RAM-only lockout state resets after power loss. MicroPython runs main.py again when the board restarts.

Delete - I don't want to use this again

Removing these files erases SecureLink from the Pico 2. The hardware stays intact for another project.

  • Stop any running script with Thonny's stop control in the top toolbar.
  • Click File in Thonny's top menu bar.
  • Select Open.
  • Choose the connected MicroPython device.
  • Hold Ctrl while selecting project_key.py, secure_channel.py, main.py, attack_demo.py, and lockout_demo.py.
  • Press the Delete key on your keyboard.
  • Approve the deletion if a confirmation prompt appears.

A second device-file check confirms that the Pico 2 no longer stores the SecureLink firmware.

  • Click File in Thonny's top menu bar.
  • Select Open.
  • Choose the connected MicroPython device.
  • Confirm that none of the five project filenames appear in the device file list.
  • Disconnect the Micro-USB cable from the Windows PC.

Nice Work!

Nice Work!

SecureLink is complete! Your Raspberry Pi Pico 2 now authenticates commands before changing its onboard LED.

You learned how to:

  • Built MicroPython firmware for the Pico 2 that turns ARM or DISARM commands into visible onboard LED changes.
  • Implemented HMAC-SHA-256 message authentication with a constant-time tag comparison.
  • Added sequence-based replay protection that rejects previously accepted packets. Proved the full defense with an RFC 4231 self-test plus repeatable tampering and replay tests.
  • Secret Mission: Added a three-strike lockout for consecutive invalid packets. Documented its RAM-only boundary as a separate production design decision.

Ready to quiz yourself?