Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save dsbaars/33ace96982773b5dc79d46551f64d467 to your computer and use it in GitHub Desktop.

Select an option

Save dsbaars/33ace96982773b5dc79d46551f64d467 to your computer and use it in GitHub Desktop.
MicroPython Vulnerabilities Blockclock

MicroPython runtime vulnerabilities — Blockclock mini

The runtime-level security issues that come from the MicroPython interpreter and its bundled TLS stack, as opposed to the app-layer protocol weaknesses documented in SECURITY_ANALYSIS.md (cleartext CBOR/TCP, unsigned replies). These are the issues that could let someone own the box, not just spoof what it displays.

The version reality

The image reports MicroPython v1.12-663-g9883d8e81, built 2024-12-11 on ESP-IDF v3.3.2. Two facts frame everything below:

  • It isn't literally 1.12 — it's the 1.12 tag plus 663 commits, a dev snapshot between 1.12 (Dec 2019) and 1.13 (Sep 2020). So the runtime is ~4 years stale at build time.
  • Most MicroPython hardening — fuzzing fixes, real ssl certificate support, WebREPL improvements — landed in 1.13–1.19, i.e. after this build's cutoff.
  • The runtime is frozen into a Bitcoin-signed image. You cannot patch MicroPython independently of Coinkite. Every issue here is only fixable by a Coinkite OTA.

Ranked by real user impact

1. WebREPL — highest severity, if reachable

The firmware freezes in webrepl.py, webrepl_setup.py, and the C module extmod/modwebrepl.c. WebREPL is a raw Python REPL over WebSocket — if it can be reached it is arbitrary code execution on your LAN: reading NVS WiFi creds, the shared BACKEND_AUTH_KEY, everything. Its 1.12-era model is weak on three counts:

  • Cleartext transport — plain ws:// (port 8266), not wss://. The password is exchanged in the clear and is sniffable on the LAN segment.
  • Password is 4–9 chars, single global secret (New password (4-9 chars): is in the strings) with no rate-limiting / lockout in this version → practical online brute force.
  • Latent footgun — even if off by default, it is one config toggle away from being a network-exposed root shell.

No static proof was found that Coinkite auto-starts it — the webrepl.start() / "manual override mode" strings are the stock library's own text, not evidence of a wired-up boot path. Honest status: shipped and capable; whether it is live is a per-device config question. If ws://<device-ip>:8266 answers, treat as critical.

2. ussl.wrap_socket defaults to no certificate verification

A genuine MicroPython 1.12 property, not just an app bug. This version's ussl has no working cert_reqs=CERT_REQUIRED path on ESP32, and the call site carries no cert args (confirmed: wrap_socket, server_hostname, do_handshake present; no cert_reqs / CERT_* qstrs). Every "HTTPS" the device makes completes against any server certificate:

  • Blockstream blockstream.info calls (Opendime balance / UTXO lookups) — fully MITM-able by anyone on-path.
  • The TLS leg of OTA — but OTA is saved by the separate Bitcoin signature on the .bin, so integrity survives; only confidentiality is lost there.

3. Bundled mbedTLS is old (ESP-IDF 3.3.2 → mbedTLS 2.16.x)

The TLS stack dates to early 2020 and carries the known 2.16.x CVE set (Lucky-13-style timing, Bleichenbacher-oracle and side-channel issues, etc.). Cert validation would normally blunt these, but combined with #2 the confidentiality of the Blockstream path is already gone; the mbedTLS age mainly adds DoS/downgrade surface.

4. Remote crash / DoS via the interpreter's C parsers

1.12 predates most fuzzing-driven memory-safety fixes. The untrusted inputs that reach C code on this device are the WebSocket framing (modwebrepl.c — note the size == 1, buf_sz == 1 assertion strings, exactly the edges fuzzers poke) and HTTP request/header parsing. A malformed frame realistically yields a reboot/DoS of the display; memory-corruption-to-RCE is plausible but far harder.

Mitigating nuance: the backend CBOR channel is parsed by a pure-Python cbor module, not C. So even though an on-path attacker fully controls those replies, the worst they get through the parser is a Python exception or memory-exhaustion DoS — not a C buffer overflow. This meaningfully lowers the risk of the scariest channel.

5. Secrets in readable NVS + no independent patch path

Whether Secure Boot / Flash Encryption is enabled is a per-unit eFuse decision undetermined by the image (see CUSTOM_FIRMWARE_FEASIBILITY.md; the fact that UART custom-flashing is feasible implies many units ship with them off). Where they are off, a physical attacker can dump flash and pull the WiFi PSK and the shared BACKEND_AUTH_KEY from NVS.

The secp256k1 implementation is sound (libngu), but the entropy that feeds it is not — see finding #6. Don't read "uses a proper crypto library" as "the crypto is safe."

6. Weak entropy feeding the secp256k1 / libngu crypto

Correcting the framing that a "proper crypto library" implies safe crypto: the implementation (libngu's constant-time secp256k1) is fine, but on a Bitcoin-adjacent device the part that matters is the entropy behind it, and that is the weak link.

  • The RNG path bottoms out in the ESP32 hardware RNG (esp_fill_random, hw_random.c) via libngu/ngu/random.c. Espressif documents that esp_random() returns true random numbers only while the RF subsystem (WiFi/BT) is running; called with the radio off — early boot, or before the device associates — it degrades to a weak, largely deterministic source.
  • In practice the generator was found not to be drawing actual hardware randomness for its entropy, so values that must be unpredictable (secp256k1 signing nonces, any on-device key/seed material) can be biased or guessable rather than random.
  • Consequence: a predictable ECDSA nonce lets an observer recover the signing private key from as few as two signatures; weak seed entropy makes any generated key guessable. This is the classic way deployments that use a "proper crypto library" still lose keys — the library is only as strong as the randomness it's handed.

Blast-radius caveats specific to this device (why it's ranked here, not at the top):

  • The only secp256k1 signing in normal operation is the backend auth handshake, which uses the fleet-shared BACKEND_AUTH_KEY that is already extractable from firmware — so recovering it via nonce leakage yields nothing an attacker didn't already have.
  • The device reads Opendime addresses rather than minting spending keys on-chip (the Opendime seals its own key), so no obvious high-value key is generated with this RNG. The firmware also carries an Opendime entropy-load path (Opendime entropy load starting, OD_ADD_ENTROPY, OD_GET_ENTROPY_COUNT) — an attempt to source entropy externally, which mitigates the ESP32 weakness when an Opendime is present.
  • If libngu signs with RFC 6979 deterministic nonces (standard in Bitcoin tooling), the signing path needs no RNG at all and is unaffected — worth confirming before assuming signatures leak the key.

Net: the weakness is real and "not a weak spot" was the wrong call, but for this product's current operations the practical exploitability is bounded by the shared-key and read-only-address facts above. It becomes critical for any firmware variant that generates keys or seeds on-device.

What an owner should do

  • Segregate it. Put the Blockclock on a guest/IoT VLAN. Its LAN-facing services — the admin HTTP Digest-auth UI and WebREPL (if live) — are the real exposure. Treat anyone on the same segment as able to reach a possible root shell.
  • Check for / disable WebREPL and confirm the admin password is set. This is the one issue that turns "annoying display spoof" into "device takeover."
  • Don't trust its "HTTPS." No cert validation + old mbedTLS = MITM-able. Fine for ambience, not for anything you act on.
  • Physical access = secret extraction on units without flash encryption; rotate WiFi creds if a unit leaves your control.

What was verified vs. inferred

Claim How established
Build is v1.12-663-g9883d8e81, ESP-IDF v3.3.2 Version strings in latest.bin
WebREPL is frozen into the image webrepl.py, webrepl_setup.py, extmod/modwebrepl.c in strings + module table
WebREPL password is 4–9 chars, cleartext ws Stock library strings (New password (4-9 chars):, Sec-WebSocket-Key); standard MicroPython 1.12 behavior
WebREPL auto-start on this unit Not proven — no wired-up boot path found statically; config-dependent
ussl call site has no cert args → CERT_NONE qstr/string search: wrap_socket + server_hostname + do_handshake, no cert_reqs/CERT_* (matches SECURITY_ANALYSIS bytecode read)
mbedTLS ≈ 2.16.x Implied by ESP-IDF 3.3.2 bundle; MBEDTLS_ERR_* strings present
Backend CBOR parsed in pure Python cbor/cbor.py etc. in frozen module table
secp256k1 implemented by libngu; entropy path is the ESP32 HW RNG libngu/ngu/random.c, secp256k1*, esp_fill_random, hw_random.c strings
ESP32 esp_random is a TRNG only while RF (WiFi/BT) is active Documented Espressif behavior
Generator observed not using true hardware randomness for entropy Reverse-engineering finding — not re-derived from static strings in this doc
Firmware also has an Opendime entropy-load path (partial mitigation) Opendime entropy load starting, OD_ADD_ENTROPY, OD_GET_ENTROPY_COUNT strings
Secure Boot / Flash Encryption status Per-unit eFuse, not determinable from image (see CUSTOM_FIRMWARE_FEASIBILITY.md)

Blockclock mini — security analysis

What's protected, what isn't, and what an attacker can actually do. Reverse-engineered from latest.bin and verified live against na.blockclockmini.com:21021 (see Z_BACKEND_PROTOCOL.md for protocol details and poc_query_backend.py for a working client implementation).

TL;DR

The device↔backend channel is plain TCP with cleartext CBOR payloads. No transport encryption, no per-frame MAC, no session key. Anything on the network path between the device and Coinkite can read every reply and modify any reply, and the device will display the modified value with no detection capability.

This is OK in the context of what is being exchanged (public market data), but it's surprising for a Bitcoin device, so it's worth documenting clearly.

Evidence

  1. defaults.py stores the backend URL as 'na.blockclockmini.com' — no scheme prefix.
  2. backend.py URL parser treats no-scheme as tcp. tls:// would trigger ussl.wrap_socket, but isn't used.
  3. Direct verification: poc_query_backend.py connects with vanilla socket.create_connection() (no TLS) and exchanges CBOR frames with the live server. The fact that cbor2.loads() returns sensible decoded data (BTC price, block height, MSTR holdings) is proof the channel isn't encrypted — garbled ciphertext would not parse as valid CBOR.

What is protected vs. what isn't

Layer Protection Notes
WiFi → your AP Encrypted by WPA2/WPA3 (your AP's responsibility) Outside the device's control
AP → Internet → Coinkite None. Cleartext. Any on-path observer (ISP, malicious AP, network admin, state actor) sees every frame
Application-layer auth (client → server) secp256k1 ECDSA signature over sha256(sha256(uuid ‖ '\n' ‖ challenge)) using the firmware-baked BACKEND_AUTH_KEY Proves "client knows the firmware key" — but every Blockclock mini ships with the same key, so anyone with the firmware (i.e. anyone) has it. More like firmware-genuineness attestation than real auth.
Server-side signatures on responses None. Every server reply is unsigned. Whatever speaks the wire protocol shape is trusted by the device
Replay protection Partial — per-connection. Server's 32-byte random challenge makes each handshake unique. But individual replies inside a session aren't replay-protected
Confidentiality of payloads None. CBOR-in-cleartext over TCP
Firmware integrity (separate channel) OTA .bin is Bitcoin-signed by Coinkite. 10 secp256k1 pubkeys (FIRMWARE_PUBKEYS in authdata.py) validate. This is well-protected. Unsigned firmware won't load.
Blockstream API calls (Opendime only) Over HTTPS… …but MicroPython's ussl.wrap_socket(…) is called without cert_reqs=CERT_REQUIRED or a CA bundle, so it defaults to CERT_NONE: the TLS handshake completes against any server cert. Effectively no cert validation.

What an on-path attacker can do

With visibility into the TCP stream between the device and na.blockclockmini.com, an attacker can:

  • Read every value the device displays in real time by decoding the CBOR payloads. Including which tags the device is showing, what groups are configured, the auth handshake (and from it, the device's internal UUID).
  • Modify any server reply silently. The device will render whatever numbers/strings arrive in a syntactically-valid tag_value reply. Possible attacks:
    • Show a fake block height
    • Show a fake BTC/USD price (e.g. trick the owner into thinking BTC has crashed)
    • Show a fake MSTR holdings figure
    • Cause it to render 666666 whenever someone walks into the room
    • Push is_error: true to make all displays go dark
  • Inject extra tags by adding them to get_groups replies; the device's display picker will offer them in the admin UI.
  • Fingerprint the device by its UUID across reconnects.

The attacker cannot (without separately compromising the OTA flow):

  • Push runnable firmware modifications (those are signed with private keys the attacker doesn't have)
  • Read anything the device hasn't subscribed to (the protocol is request/response — the attacker sees the same data the device sees)
  • Compromise the Opendime contents directly (the Opendime is a sealed hardware device; the Blockclock only reads its address)

Why it's like this (and why it's not necessarily a security failure)

The threat model Coinkite designed for makes this reasonable:

  1. The data is fully public. Block height, BTC/USD, public-company BTC holdings — anyone can fetch these from dozens of free APIs. There's no secret in the channel.
  2. TLS in MicroPython 1.12 on a 4 MB ESP32 is expensive. mbedTLS pulls in a substantial code+RAM footprint, and shipping a CA bundle means firmware updates whenever Let's Encrypt rotates roots, when intermediates expire, etc. Plain TCP + an application-layer auth sig dodges both costs.
  3. The firmware itself is well-protected. The OTA chain uses Bitcoin-style multi-key signatures (10 pubkeys, threshold scheme presumably) which is exactly where Coinkite invested their security budget.
  4. Bitcoin holders' real threat surface is the Opendime/Coldcard side, not what their wall-mounted block-height display shows. Coinkite presumably reasoned that ruining someone's vibe by spoofing block-height numbers isn't a realistic attack worth defending against.

That said, a Bitcoin-themed product showing completely unverified data is a surprising design call given the audience's usual expectations about trustlessness. If a user makes a financial decision based on what the Blockclock shows, they're implicitly trusting the network path, not Coinkite's data.

Practical consequences

For users

  • Don't make financial decisions based on Blockclock readings if you don't trust your network path
  • The display is for ambience and information — treat it like a clock, not an oracle
  • Your ISP knows which tags your device is configured to show (minor privacy leak)

For self-hosters / hackers

  • You don't need to modify the firmware to redirect the device to your own backend. DNS hijack na.blockclockmini.com → your server, speak the protocol, done. See BACKEND_PROTOCOL.md for the wire spec.
  • No cert pinning, no CA bundle in flash — there's nothing to defeat.
  • The auth handshake uses a key (BACKEND_AUTH_KEY = 29b4c14c…) that is identical on every device, so you can either accept any incoming handshake or verify it with the public key derived from that.

For Coinkite (if they read this)

  • The fix is relatively cheap given that the protocol already supports tls://: just deploy the server on a TLS port and ship a defaults update with tls://na.blockclockmini.com and set cert_reqs=CERT_REQUIRED + a CA bundle (or, simpler, a pinned Coinkite cert). The qstrs cert_reqs aren't currently in the firmware, so this would be a small code change in backend.py.
  • A server-side signature on every reply (one short secp256k1 sig per CBOR frame, signed with one of the existing firmware pubkeys) would add server-authenticity without requiring TLS at all.

What I verified vs. what I inferred

Claim How verified
Backend URL has no scheme, defaults to tcp Extracted from defaults.py const_table
BEConnection.start() does plain TCP when scheme isn't tls Read from bytecode in backend_disassembly.txt
Server accepts plain-TCP connections on port 21021 Verified livepoc_query_backend.py connected and exchanged frames
CBOR payloads are cleartext on the wire Verified livecbor2.loads() returned valid decoded dicts
Server's first frame contains {min_version, challenge} Verified live — captured the frame
Synthesized UUID is accepted with valid sig Verified live — handshake succeeded with random UUID
ussl.wrap_socket call site has no cert_reqs arg Read from bytecode — CALL_METHOD 513 (1 positional + 2 kwargs server_hostname, do_handshake)
MicroPython 1.12 ussl defaults to CERT_NONE Standard MicroPython behavior, well-documented upstream
Blockstream HTTPS calls also lack cert validation Inferred — same MicroPython ussl default applies; the actual call site in the Opendime balance path wasn't traced in detail
OTA firmware is Bitcoin-signed "Valid Bitcoin signature for nonce: %s" string in firmware + FIRMWARE_PUBKEYS dict in authdata.py

Blockclock mini — backend RPC protocol

Wire-level spec for the protocol the device uses to talk to Coinkite's relay servers. Reverse-engineered from backend.py's bytecode (full disassembly in backend_disassembly.txt) and verified end-to-end against the live na.blockclockmini.com:21021 server by poc_query_backend.py (see logs in the chat history; or just re-run it).

The full tag catalog (every available cm.*, treas.*, coinbase.*, etc., with sample values and the rendered panel layout) lives in a separate file: BACKEND_TAG_CATALOG.md.

For the security analysis (what's protected vs. cleartext, what an on-path attacker can do, why it's like this), see SECURITY_ANALYSIS.md. Short version: the device↔backend channel is plain TCP with cleartext CBOR — no transport encryption, no cert pinning, no per-reply signatures.

All facts below are taken from compiled bytecode in latest.bin. Where the bytecode is unambiguous I cite it; where I'm extrapolating I say so.

Where to find the server

From defaults.py (URLS.backend + BACKEND_SERVERS):

URL Region
na.blockclockmini.com N. America (default)
europe.blockclockmini.com Europe
asia.blockclockmini.com Asia

Port: 21021 (DEFAULT_BE_PORT constant in defaults.py).

URL format on the wire: <scheme>://<host>[:<port>]/<...>. Schemes parsed in BEConnection.start():

  • tcp (default if no scheme prefix) — plain TCP
  • tls — TCP wrapped in TLS via ussl.wrap_socket(sock, server_hostname=host, do_handshake=True)

Coinkite ships with the default backend over plain TCP (no tls:// prefix in the embedded URL). The 21021 listener presumably already takes care of transport security at its own layer, or the client-signed auth is considered sufficient — the device does not validate a TLS cert in the default config.

Frame format

Every direction on the wire uses the same fixed frame envelope:

+--------+--------+--------+--------+---- ... ----+
| 0xBC   |   length (24-bit, big-endian)         |
+--------+--------+--------+--------+---- ... ----+
                                    | CBOR payload (length bytes)    |
                                    +--------------------------------+

Specifically, the first 4 bytes are pack('>I', 0xBC000000 | len) — i.e. magic byte 0xBC followed by a 24-bit big-endian length, then length bytes of CBOR-encoded payload. The receive loop in BEConnection.operate() does:

async def operate(self):
    s = self.s
    while True:
        hdr = await s.readexactly(4)
        assert hdr[0] == 0xBC, 'bad magic'           # else log error and bail
        length = unpack_from('>I', hdr)[0] & 0x00FFFFFF
        assert length < MAX_MSG_RX, 'too long'        # MAX_MSG_RX = 262144
        assert length > 0, 'too short'
        payload = await s.readexactly(length)
        msg = cbor.loads(payload)
        await self.rx(msg)

Constants (from backend.py module-level):

  • MAX_MSG_RX = 262144 — 256 KB receive limit
  • MAX_SERVER_BACKLOG = 6 — outstanding requests cap

send() is symmetric:

async def send(self, msg):
    body   = cbor.dumps(msg)
    header = pack('>I', 0xBC000000 | len(body))
    if msg.get('cmd') != 'client':
        L.debug('Tx to backed: %r', msg)              # note: typo "backed" is in the firmware
    self.s.write(header)
    self.s.write(body)
    await self.s.drain()

Every CBOR payload is a top-level map (dict). The standard keys are:

Key Direction Meaning
cmd C→S request command name (e.g. client, ping, get_value)
arg C→S command argument (any CBOR value)
reqid C↔S random 6-digit request id, copied back in the response for correlation

Replies that originate from a request the device asked for carry the reqid of that request; unsolicited messages (push notifications, auth challenge) don't.

Authentication

The device authenticates with secp256k1 ECDSA, using a BACKEND_AUTH_KEY private key baked into the firmware (imported from the authdata frozen module).

async def send_auth_response(self, challenge):
    uuid      = hw.my_uuid()                              # device UUID string
    digest    = sha256(sha256(uuid.encode() + SEP + challenge).digest()).digest()
    signature = ngu.secp256k1.sign(BACKEND_AUTH_KEY, digest, 0).to_bytes()
    await self.send({
        'cmd': 'client',
        'arg': {
            'uuid':    uuid,
            'sig':     signature[1:],     # strip leading recovery/flag byte
            'version': VERSION,
        },
    })
    await self.send_initial_stuff()
    from wifi import NETWORK
    NETWORK.good_be_connection()

Notes:

  • SEP = b'\n' — the static qstr #3 resolves to a single newline byte (\x0a). Confirmed by walking the static qstr pool's third entry.
  • BACKEND_AUTH_KEY (extracted from authdata.py const table at file 0x0009a250):
    29b4c14c063d99fe9843eeae7619d2e78906062881c7bbea1befaa866d8ee234
    
    32 bytes. Same private key in every Blockclock mini. This isn't per-device authentication — it's "this is genuine Blockclock firmware" attestation. Verified live: the server accepts any UUID format paired with a correctly-signed challenge response, so no per-device binding is enforced.

Server's first frame (verified live)

Immediately after the TCP connect (no client-side data sent yet), the server sends a single unsolicited frame:

{
    "min_version": [0, 1, 1],
    "challenge":   <32-byte ASCII string, e.g. "3FNXGIXU2UZJ5I3DDCWVYUN6CO4MPNHP">
}

min_version is the minimum acceptable firmware version (here, the device must claim >= [0,1,1] in its client auth reply's version field). The challenge is fresh per-connection random bytes that get hashed into the auth signature.

So the auth flow is actually:

  1. TCP connect
  2. Server sends {min_version, challenge} (no reqid)
  3. Client signs sha256(sha256(uuid + b'\n' + challenge).digest()) with BACKEND_AUTH_KEY and sends {cmd: 'client', arg: {uuid, sig, version}}
  4. Server validates and the connection is open

The "where does send_auth_response get called from?" loose end from the original firmware-only analysis: there's bytecode in backend.py (probably in operate() or a wrapper task I didn't fully trace) that recognizes the no-reqid first frame as the auth challenge and dispatches send_auth_response(challenge).

  • The challenge argument has to come from somewhere — most likely the first inbound frame after TCP+TLS connect is a server-initiated message containing the challenge bytes. rx() as disassembled only handles reqid-bearing messages (it pops reqid with default None and silently returns if absent), so the challenge handling probably lives in a path I didn't trace — possibly in the WebSocket variant (WSClient is imported by backend.py too), or there's a pre-operate() direct read I haven't followed.
  • The signature has its first byte dropped before sending (signature[1:]). For secp256k1 signatures from ngu.secp256k1.sign the first byte is typically the recovery id; here it's stripped, so the server gets only the 64-byte (r,s) pair.
  • BACKEND_AUTH_KEY is the same private key in every device, baked into firmware. Anyone with a firmware copy can extract it from authdata.py's bytecode (its mp_raw_code_t is in the frozen-module table). This isn't real per-device authentication — it's more like "this is genuine Blockclock firmware" attestation.

After-auth handshake — send_initial_stuff()

async def send_initial_stuff(self):
    await self.send_cmd('timezone', SETTINGS.get('tz_name'))
    if SETTINGS.get('swap_sep', 0):
        await self.send_cmd('swap_sep', True)
    if SETTINGS.get('hours24', 0):
        await self.send_cmd('hours24',  True)
    await self.send_cmd('get_groups')

So once authenticated, the client tells the server its display preferences (timezone, decimal-separator preference, 12h-vs-24h) and then immediately asks for the full menu of display groups via get_groups.

RPC pattern — send_cmd vs send_cmd_await

async def send_cmd(self, cmd, arg=None):
    await self.send({'cmd': cmd, 'arg': arg})

async def send_cmd_await(self, cmd, arg=None):
    reqid = random.randint(100000, 999999)
    self.pending_requests[reqid] = asyncio.Event()
    await self.send({'cmd': cmd, 'arg': arg, 'reqid': reqid})
    await self.pending_requests[reqid].wait()
    del self.pending_requests[reqid]
    return self.pending_response.pop(reqid)

So send_cmd is fire-and-forget; send_cmd_await blocks until the matching reqid reply is delivered by rx() (which sets the asyncio Event and stores the response in self.pending_response[reqid]).

rx() for already-pending requests:

async def rx(self, msg):
    L.debug(', '.join(msg.keys()))
    reqid = msg.pop('reqid', None)
    if reqid and reqid in self.pending_requests:
        self.pending_response[reqid] = msg
        self.pending_requests[reqid].set()
    # messages without a matching pending reqid are dropped silently

Command catalog

All commands observed in backend.py's disassembly:

Outbound (client → server)

cmd arg shape reqid? Purpose Source
client {uuid: str, sig: bytes, version: str} no Initial auth send_auth_response()
timezone str (e.g. "America/Toronto") no Tell server the user's timezone send_initial_stuff()
swap_sep True no Use comma as the decimal separator (Eu style) send_initial_stuff()
hours24 True no Use 24-hour clock instead of 12-hour send_initial_stuff()
get_groups None (fire-and-forget) Ask for the list of available display groups; reply comes later as an unsolicited message send_initial_stuff()
get_group <group_name: str> yes Fetch the tags inside one group → reply: {group_info: [...]} BEConnection.get_group_tags()
get_value <tag: str> (e.g. cm.markets.price) yes Fetch the current rendered value → reply: {tag_value: <serialized DisplayValue>} BEConnection.get_rendered()
ping (presumably timestamp/nonce) yes Heartbeat → reply pong BEConnection.test_ping()

Inbound (server → client)

Trigger Shape Handled by
reply to any *_await request top-level dict with reqid matching, plus the cmd-specific data rx()
auth challenge first message after connect (presumably) — contains the bytes to sign (path not fully traced)
btc_block_event server pushes when a new BTC block is mined; sets self.btc_block_event asyncio Event (presumably handled in unsolicited-message path)
pong {cmd: 'pong', ...} rx() (must match the ping's reqid)

DisplayValue (the tag_value field) — verified live shape

Returned by get_value. The full set of fields (verified by querying ~270 live tags):

Field Type Description
tag str the tag name itself (echoed back)
label str human-readable description, e.g. "Strategy - BTC held"
contents list of 7 str what gets rendered on each of the 7 e-paper panels; e.g. ["/MSTR/BTC", "8", "1", "8", "8", "6", "9"]
tl_text str "top-left" small-text label (typically same as label)
br_text str / None "bottom-right" small-text label (extra info / error message)
number float / None numeric value if applicable
string str / None string value if applicable (mutually exclusive with number)
pair list / None for currency pairs, e.g. ["BTC", "USD"] or ["MSTR", "BTC"]
is_error bool true if the value is unavailable (panels show ------)
omit_line bool / None true to hide the tl_text/br_text line

Example for treas.pub.Strategy.hodl:

{
  "tag": "treas.pub.Strategy.hodl",
  "label": "Strategy - BTC held",
  "contents": ["/MSTR/BTC", "8", "1", "8", "8", "6", "9"],
  "tl_text": "Strategy - BTC held",
  "br_text": null,
  "number": 818869.0,
  "string": null,
  "pair": ["MSTR", "BTC"],
  "is_error": false,
  "omit_line": false
}

The device's render path is therefore trivially simple: for each panel index 0..6, render the corresponding string from contents to the e-paper at that position. The strings can be single digits ("8"), single symbols ("$", "."), short labels ("/USD"), or multi-line labels ("/MSTR/BTC" is three stacked lines).

backend.py's get_fiat_exchange() validates pair[0] == 'BTC' and returns (pair[1], number) — i.e. ('USD', 78240).

Default initial tag

The device's pre-configured first display is cm.markets.price (DEFAULT_TAG in defaults.py). The full set of tags is fetched at runtime via get_groups + get_group — see DISPLAY_MODES.md for why the tag list isn't hardcoded.

Concurrency model

BEConnection is fully async (uasyncio coroutines):

  • One operate task that reads frames in a tight loop
  • Many concurrent *_await calls synchronized through per-reqid asyncio Events
  • A separate btc_block_event for unsolicited block-found push notifications (other code awaits this to refresh display)

A connection is held open; ping/pong heartbeats keep it alive across network transitions. WSClient (a websocket-monitor task spawned in hw.py's top-level) watches connection health.

How to talk to the server yourself

If you wanted to query the live backend from your own code (e.g. to get the authoritative tag list right now), you would need:

  1. The BACKEND_AUTH_KEY. It's the same in every Blockclock mini. Extracting it from the firmware means disassembling authdata.py (frozen-module index — find via the names blob at file 0x0000e9d4) and locating the bytes literal in its const_table. Same approach that worked for the digit fonts in fonts.py.
  2. A device UUID to claim. hw.my_uuid() is implemented in sigheader.py. The format isn't documented here.
  3. The exact SEP separator used in the auth-hash construction (the unknown static-qstr-#3 byte). Try b'', b':', b'/', b'.', b' ' and see which one the server accepts.
  4. Stand up a TCP socket to na.blockclockmini.com:21021, do the auth handshake, then send_cmd('get_groups') and read the reply. You'll get the authoritative tag namespace including whatever cm.treasuries.mstr.btc_held (or however MSTR's holdings are labeled) is actually called.

A sniff of a real device's traffic would answer (3) and reveal the exact challenge-bytes flow.

Live-verified unsolicited push catalog

In addition to challenge frames and reqid-matched replies, the server sends these unsolicited frames (observed during the catalog crawl):

Frame keys When Purpose
{min_version, challenge} Immediately after TCP connect Auth challenge (see above)
{reqid: 0, set_time, common_tags, your_ip} After get_groups request Time sync + the list of "common" pinned tags + your public IP. The reqid: 0 (= sentinel for unsolicited) indicates this isn't tied to a specific request id
{reqid: 0, all_groups: [[short, label, desc], ...]} Shortly after The full group catalog
{set_time: [Y,M,D,h,m,s,wday,subsec]} Periodically Server-pushed time for the device's RTC

btc_block_event is also defined in backend.py as an asyncio.Event that gets set when a new block is found — presumably triggered by a push frame with a specific marker (didn't fire during my short test window).

Status of previously-open items

All open items from the original firmware-only analysis are now closed:

  • SEP = b'\n' (newline byte) — confirmed from static qstr pool walk
  • ✅ How the challenge handshake works — server pushes challenge as first frame; client signs it (see "Server's first frame" above)
  • ✅ Full DisplayValue field set — see table above
  • ✅ Unsolicited push catalog — see above
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment