Skip to content
On this page0%0%
  1. Why do this at all
  2. The opponent’s stat sheet
  3. Round zero: does int even exist here?
  4. A 64-bit word that isn’t there
  5. Arithmetic by hand
  6. Three implementations, one table
  7. The rig
  8. Python writes Java
  9. How slow is slow?
  10. What the slowness buys
  11. Sources
All dispatches
16 min read

Running a Modern Hash Function on a 20-Year-Old Chip Design

A lab notebook on implementing BLAKE2b in pure JavaCard bytecode—and the 144-millisecond price of taking a 64-bit hash function off-menu.

javacard
cryptography
blake2b
embedded
performance
A black smartcard with a gold contact chip as wide blue data lanes narrow into the card's tiny processor

In which I implement BLAKE2b — a hash function built to saturate 64-bit pipelines — in pure bytecode on a smartcard whose native word is 16 bits, whose 32-bit integers are optional equipment, and whose only I/O is a two-byte status word. A lab notebook about the price of going off-menu.


In one corner: BLAKE2b, introduced in 2013 by Aumasson, Neves, Wilcox-O’Hearn, and Winnerlein in a paper titled, with admirable directness, BLAKE2: simpler, smaller, fast as MD5, and standardized in 2015 as RFC 7693. The home page escalates the claim — faster than MD5, SHA-1, SHA-2, and SHA-3 — and the RFC is specific about how: BLAKE2b “is optimized for 64-bit platforms.” It eats 128-byte blocks through twelve rounds of a function called G, all of it arithmetic on 64-bit words, and on an ordinary desktop core it runs at about three cycles per byte — a gibibyte per second.

In the other corner: a JavaCard secure element — the chip family inside SIM cards and bank cards. Native word size: 16 bits. The long type does not exist. The int type is, per the Java Card VM specification, optional — the spec’s words are that it “need not be supported.” Every bytecode is interpreted; there is no JIT. Every array access is bounds-checked at runtime. The working memory budget is a few kilobytes, and the persistent memory wears out if you write it too often.

There is no version of this that ends well, which is why I wanted to try it.

Why do this at all

I’ve written before about JavaCard’s fixed crypto menu: the platform’s entire value is a short list of algorithms implemented in hardened silicon — fast, constant-time, certified — and if the primitive you want isn’t on the list, the officially supported options are (a) want something else or (b) wait for the industry, which moves in decade increments.

But there’s a third option nobody advertises: implement it yourself, in bytecode, on top of the VM. Card people talk about this the way sailors talk about sea monsters. It’s understood to be possible and understood to be ruinous, and concrete numbers are surprisingly hard to find. Meanwhile the question actually matters: a whole class of secure hardware signs digests it didn’t compute. If the device can’t run the hash, it must trust whatever computed the digest — and “trust” is doing heavy lifting in that sentence. The gap between can’t and won’t run the hash is measured in milliseconds, and I wanted the number.

So: BLAKE2b, chosen precisely because it’s the worst case that’s still mainstream. It’s everywhere — Argon2’s memory-filling core is built on its round function, libsodium ships it, b2sum sits in GNU coreutils next to md5sum, RAR archives can checksum with a variant of it, WireGuard’s handshake runs its smaller sibling — and its entire design leans on the one thing this chip doesn’t have: wide words.

(An honesty note before anyone writes in: the spec itself would point a card-class CPU at BLAKE2s, the sibling tuned for 8-to-32-bit machines. But the formats deployed in the world overwhelmingly speak BLAKE2b, and the experiment is about meeting deployed reality, not about choosing a fair fight.)

The opponent’s stat sheet

A BLAKE2b compression processes one 128-byte block through a 16-word working vector — sixteen 64-bit words. Twelve rounds; each round calls the G function eight times; each G does six additions, four XORs, and four rotations on 64-bit values, with rotation distances of 32, 24, 16, and 63 bits. That’s 96 G calls per block: 1,344 sixty-four-bit operations per compression — 576 additions, 384 XORs, 384 rotations — before you count message loads or setup. Decompose each of those into 32-bit halves with hand-synthesized carries and you land somewhere north of six thousand integer operations per block, every one of them an interpreted, checked bytecode.

On hardware with 64-bit registers, each of those operations is one instruction, often less after vectorization. On my card, each is a small manual ceremony:

  • A 64-bit word has to be represented, because no type holds it.
  • A 64-bit addition has to carry, in a language with no unsigned comparison and no carry flag.
  • A 64-bit rotation has to be assembled from shifts across two half-words.

And the interpreter charges by the step.

Round zero: does int even exist here?

Before optimizing anything, there’s a prior question: does this particular card implement optional 32-bit arithmetic at all? Without int, every 64-bit word becomes four 16-bit limbs and the ceremony count roughly doubles.

The elegant thing about JavaCard’s deployment model is that you don’t need a datasheet to answer this — the platform answers it structurally. You set the flag that tells the converter to allow int bytecodes (ints="true" in the build), and then you try to install the result. A card without int support must refuse the CAP file at load time. The failure mode is the answer. I built the probe so that a rejection would be a clean data point rather than a debugging session — and the card accepted it. Thirty-two-bit arithmetic: available. (The card in question is a stock NXP JCOP-family developer card running the 2015 edition of the spec, which is what you get when you buy a JavaCard at retail in 2026.)

That acceptance, by the way, is the only “feature detection” in this whole story that didn’t cost an afternoon.

A 64-bit word that isn’t there

The obvious representation is an int[] — thirty-two ints for the working vector, two per 64-bit word. Here the platform got in its first real punch. JavaCard’s transient-memory API — the escape hatch that gets you RAM instead of wear-prone, slow persistent memory — offers makeTransientByteArray, makeTransientShortArray, makeTransientBooleanArray, and makeTransientObjectArray.

There is no transient int array. The type that’s optional in the language is also missing from the memory API. Your options for int[] are persistent memory — where every one of the thousands of writes per block is a flash write, which is slow (on comparable current hardware, the same 256-byte copy measures ~31× slower to persistent memory than within RAM) and, worse, finite — or nothing.

The resolution sounds absurd and is, I think, the single most JavaCard sentence I will ever write: the entire sixteen-word working vector lives in thirty-two local variables.

int v0l = (h[1] << 16) | (h[0] & 0xFFFF);
int v0h = (h[3] << 16) | (h[2] & 0xFFFF);
int v1l = (h[5] << 16) | (h[4] & 0xFFFF);
// ... twenty-nine more of these

Locals live on the VM stack, which lives in RAM, which is fast and doesn’t wear out. The chain state and the message block still live in transient short arrays (those exist), decomposed into 16-bit limbs and reassembled into int halves on the way in. There’s a small extra indignity at that boundary: BLAKE2 is little-endian, by explicit majority vote of its intended platforms, while JavaCard’s byte-order helpers are big-endian — the card is the minority the majority outvoted, so every message word gets byte-swapped by hand on arrival. The result is a memory hierarchy — flash for constants, transient arrays for state, stack locals for the hot loop — designed entirely around which allocation functions the API deigned to include.

In fairness to the designers, they did think about small machines: the paper advertises that BLAKE2b needs only 336 bytes of working state, a figure aimed at microcontrollers that lands, coincidentally, well inside a card’s few kilobytes of transient RAM. Space was never the problem here. Time was.

Arithmetic by hand

Addition first. A 64-bit add is two 32-bit adds plus a carry — but detecting the carry needs an unsigned comparison, and Java’s ints are signed. The standard trick: flipping the sign bit of both operands turns unsigned comparison into signed comparison.

t  = al + bl;
ah = ah + bh + (((t ^ 0x80000000) < (al ^ 0x80000000)) ? 1 : 0);
al = t;

Three lines per addition. There are 576 additions per block. You develop feelings about this.

Rotations are where BLAKE2’s designers accidentally did me a favor. The four rotation distances — 32, 24, 16, 63 — are love letters to real CPUs: the paper explains that 24 was chosen because an SSSE3 byte-shuffle instruction can do it two-at-a-time, and 63 because it’s just a doubling plus a shift. Their verdict on the change: “No platform suffers.” They had many platforms in mind; a bytecode interpreter on a smartcard was presumably not among them. And yet the kindness reaches even here, because those same choices are gentle to a machine faking 64-bit words in int pairs:

  • rotr 32 is free: swap the two halves. Fused with the XOR that precedes it, it’s not even a swap — just write each XOR result into the other half’s variable.
  • rotr 16 keeps everything aligned to the limbs the state already lives in.
  • rotr 63 is rotl 1: two shifts and an OR per half.
  • rotr 24 is the only one that costs honest shuffle work:
t  = bl ^ cl;  u = bh ^ ch;
bl = (t >>> 24) | (u << 8);
bh = (u >>> 24) | (t << 8);

A hash function tuned for 2012 Intel turns out to degrade gracefully all the way down to a smartcard. Nobody planned that. It’s the kind of luck you only collect by going off-menu.

Three implementations, one table

I wrote it three times, because the first two answers weren’t good enough and because the differences between them are the actual experiment. All three produce byte-exact RFC 7693 digests on hardware — the empty string and "abc" test vectors, checked before a single timing was taken, because an optimized wrong hash is a special kind of embarrassing.

Variant 64-bit words as ms per compression
Portable 4 short limbs, state in transient arrays 886
Int pairs 2 int halves, state in arrays, G as loads/stores 395
Unrolled 2 int halves, whole vector in 32 int locals 144

The deltas are the story:

886 → 395 is the price of limb count. Halving the number of limbs roughly halves everything — the adds, the carries, the loads. Fair enough; that one I predicted.

395 → 144 is the one that teaches you what a JCVM actually is. Same int arithmetic, same algorithm — the only change is that the working vector stopped being an array and became locals, which eliminated something like forty bounds-checked array accesses per G call. On this platform, aload/astore isn’t a memory access; it’s a procedure — index check, context check, dispatch. The interpreter’s tax collector stands next to every bracket. Straight-line code through locals is the closest thing to registers the VM will sell you.

Past that, the returns die off. The remaining 144 ms is the interpreter’s floor for ~a-thousand-plus emulated wide operations: no array left to un-access, no call left to inline. The next order of magnitude lives below the bytecode, and you can’t get there from here.

The rig

You cannot printf on a smartcard, and you cannot attach a profiler. What you can do is science through the only aperture available: wall-clock time on APDUs.

The probe applet takes a count N and runs N chained compressions back-to-back. The host measures the full round trip for N values 1, 2, 4, 8, 16, and 32, takes medians over repeats, and fits a least-squares line. The slope is the per-compression cost; the intercept absorbs transport and dispatch (a no-op APDU on this reader: about 6 ms). The fit came out almost insultingly linear — 150, 286, 559, 1104, 2260, 4623 ms for N = 1, 2, 4, 8, 16, 32 — which is what you’d hope from a machine this simple: no cache to warm, no frequency to scale, no scheduler to interfere. The card is many things, but it is not noisy. Slope: 144.4 ms.

(That N=8 point is worth staring at: 1104 ms to chain eight compressions is, for practical purposes, a kilobyte of input hashed in ~1.15 seconds.)

Python writes Java

The unrolled variant has one more confession attached: I didn’t write it. A Python script writes it — takes the G-function schedule and the column/diagonal pattern, and emits the method body as straight-line Java, all ninety-six G applications’ worth of carries and shuffles inlined into the round loop, sigma indices resolved through a flat table.

Generated code is the only sane way to maintain something like this — you review the generator, not the several hundred lines of v7h = v7h + v12h + ((...) ? 1 : 0); it emits — but I want to acknowledge the stack for a moment: a Python script, emitting Java, compiled by javac, distilled by a converter into a 1999 file format, verified off-card, loaded over a protocol from the chip-and-PIN era, to be interpreted by a VM on a 16-bit-flavored secure element. Every layer of that sentence is doing real work. None of it, at any point, is fast. All of it, at every point, is checked — the CAP passed the off-card verifier without complaint, which means even this monstrosity is provably type-safe. The platform will let you do a slow, ridiculous thing, but it will not let you do an unsafe one.

How slow is slow?

Numbers want company. Some context for 144 ms per 128-byte block:

  • The same card, on-menu. The JCAlgTest project (CRoCS lab, Masaryk University) maintains measured timings for 100+ real cards, and for the current mainstream NXP JCOP4 the hardware MessageDigest engine does SHA-256 over 256 bytes in 7.92 ms — and SHA-512, the same 64-bit-word arithmetic family BLAKE2b belongs to, in 8.85 ms. The silicon demonstrably contains a fast 64-bit hash engine; the API simply will not lend it to any algorithm not on the list. The insult has a poetic garnish: BLAKE2b’s initialization vector is SHA-512’s initialization vector — the incumbent and the challenger start from the same eight constants, and one of them gets the co-processor. Net price of going off-menu on this silicon: roughly 30× per byte. (The same database also shows what “the menu” means in practice: on that card, MD5 is gone — NO_SUCH_ALGORITHM — and SHA-3, standardized in 2015, never arrived. And vendor spread is its own story: native SHA-256 over 256 bytes ranges from 7.9 ms to 68.5 ms across current cards.)
  • A modern CPU. BLAKE2b’s home-page figure is 3.08 cycles per byte — about a gibibyte per second on a 2015 desktop core. My implementation manages roughly 0.9 KB/s. That’s a gap of about six orders of magnitude: the computer in your pocket versus the computer in your other pocket, a million to one.
  • Prior art. I’m not the first tourist here. JCMathLib — the open-source library that does bignum and elliptic-curve math in pure JavaCard bytecode — publishes its numbers: on the same JCOP4 generation, one software modular multiplication costs ~180 ms, which is about six complete native ECDSA-P256 signatures (31.96 ms each, hash and point multiplication included). Its README says plainly that it “is not as efficient as a native implementation could be,” which is the most diplomatic sentence in cryptography. Software SHA-512 fallbacks are an established pattern in open-source applets, and SHA-3 and ChaCha20 ports exist. For the deeper why, the academic literature measured it years ago: one interpreted 16-bit addition on a 2009-era card clocked in at 10.6 microseconds — the unit of currency this entire port is priced in. Modern cards run bytecodes faster, but the shape of the tax is unchanged.

And yet — this is the part that surprised me — 1.15 seconds for a kilobyte is usable. Not for throughput; nothing that streams, nothing per-packet. But the operations a secure element actually performs are ceremonies: a tap, a signature, a verification, a human standing at a terminal for a second and a half either way. Inside a ceremony, a second of hashing is affordable. The feasibility threshold for off-menu crypto on a smartcard isn’t “fast.” It’s “faster than the human’s patience,” and that bar turns out to be reachable from 1999.

What the slowness buys

It would be easy to end on “interpreted bytecode is slow,” but that’s not quite the lesson. The slowness and the safety are the same line item. The bounds check that made my working vector cost 395 ms in arrays is the same bounds check that makes a memory-safety exploit in an applet a research event rather than a Tuesday. The verifier that happily chewed through my generated monstrosity is the reason mutually distrusting companies share these chips. The interpreter I spent an afternoon fighting is the moat I praised when I wrote about this platform before. You don’t get to keep the moat and skip the toll.

What you get, for the toll, is a fact I find genuinely pleasing: a chip whose design lineage predates YouTube, running a hash function from 2012 that its specification has never heard of, carrying every 64-bit word in its 16-bit hands, two ints at a time, checking every carry with a sign-flip trick — correctly, reproducibly, byte-exact against the RFC, at a stately 144 milliseconds per block.

The card never once complained. It has no way to. It just kept answering 9000, which I choose to read not as “OK” but as something closer to its whole worldview: slowly, safely, exactly what you asked.


Sources

BLAKE2:

JavaCard platform constraints (unsupported types incl. long, optional int §2.2.3.1, transient memory, no-GC guarantees):

Card measurements:

Prior art: