What happened when we took the signing key out

What it takes to build a system where the thing an attacker wants to steal was never assembled, and the five places that still doesn't save you.

I keep coming back to the 2023 Microsoft breach because of how little the attacker had to do. They had a consumer signing key from 2016, seven years old and never rotated, and with it they could mint tokens for accounts the key was never supposed to be valid for. That got them something like 60,000 State Department emails plus mail from 21 other organisations.

There was no exploit anywhere in that. The token validation path worked correctly the whole time, because it was doing its job: validating a real signature from a real key that happened to be in the wrong hands. Microsoft's first public explanation was that the key leaked through a crash dump; they later withdrew that, and the Cyber Safety Review Board's finding is that Microsoft still cannot say how it was taken. The board also called the intrusion preventable, which is the part that stings.

I've spent the last while building something on the premise that the key didn't have to be there at all, and I want to write up both what works and the four or five places where it plainly doesn't.

First, the word

"Keyless" is a bad name and I'll get it out of the way. Key material absolutely exists in this system. What doesn't exist is a complete, reusable key or bearer token sitting anywhere that a caller, an app, an admin, a backend or the target service can hold. Key material is real, it's just never whole and never in one place. If your first instinct was to point out that cryptography without keys is nonsense, you're right, and the marketing name is worse than the thing it describes.

The construction

Keys get produced by distributed key generation across a set of independent nodes, using nested Shamir. There's no dealer, so no node ever sees a whole key; at no point in the protocol does one exist to be seen. Signing is a threshold signature: each node does its share of the arithmetic over the message and returns a partial, and enough partials combine into a signature.

fig 1 · one signature, no key five independent nodes · each holds one share · none holds the key node 1 node 2 node 3 node 4 node 5 combine partials one ordinary 64-byte Ed25519 signature
Each node computes its partial from the message alone. The verifier at the far end runs verify(pk, msg, sig) and cannot tell any of this happened. The full private key is not anywhere in this diagram, including in the combiner, because there is no step that produces it.

The property that made this practical for me is that what comes out is an ordinary 64-byte Ed25519 signature verifying against an ordinary public key. The verifier calls verify(pk, msg, sig) and has no idea anything unusual happened. All the strangeness stays on the signer side, which meant I could put this in front of software I hadn't written without touching that software.

Production runs 20 nodes with a threshold of 14. A local test network runs 5 and 3. Those numbers are the whole security argument so I'd rather state them than wave at the word distributed.

Compromise behaviour splits into three ranges and I've seen people conflate two of them constantly. Up to 6 compromised nodes, nothing happens. Between 7 and 13, an attacker can deny service but cannot reconstruct the key, and there's a healing protocol that re-randomises shares and makes the stolen ones useless. At 14 the key is reconstructable and it's over. So confidentiality survives thirteen compromised nodes while availability starts degrading at seven. Those are different numbers about different properties, and quoting one when you mean the other either oversells the guarantee or undersells the operational risk depending which way you're leaning.

fig 2 · drag to compromise nodes
Note where the two boundaries sit. Confidentiality survives 13, but availability starts degrading at 7. Those are different properties and quoting one when you mean the other is the most common mistake made about this design.

Why this isn't the vault you already run

Everyone has solved key custody once already, so this is where the conversation usually stalls.

A secrets manager holds the key and guards it, so compromising the guard, or anything holding kms:Sign, gets you the key's power even if you never see its bytes. An HSM keeps it in silicon you can't extract from, but extraction was never the attack; anything that can call the HSM can use it, and Storm-0558 didn't need to extract anything from anywhere. The comparison I care about more is classic Shamir sharing, because on a whiteboard it looks almost identical to what I'm describing and in the threat model it's the opposite. Shamir splits a key across parties and then puts it back together in one process to use it, and that reassembly step is the entire target. Threshold signing never reaches that step.

fig 3 · splitting is not the same as never assembling classic Shamir sharing threshold signing share share share WHOLE KEY ▲ the attack aims here share share share SIGNATURE nothing to aim at
These look almost identical on a whiteboard, which is why the distinction gets lost. The difference is the box on the left: one process, holding the whole key, for as long as the operation takes. On the right there is no equivalent box, because there is no step that builds one.

If your response is that this is just FROST with extra steps, that's mostly fair, and I'd rather concede it than argue. The threshold signing itself is well-trodden. What I found interesting was what becomes possible once no component can sign alone: password verification with nothing stored anywhere (a peer-reviewed protocol called PRISM, where no node learns the password or anything usable offline), token issuance where every node independently checks the claims before contributing its partial, and admin changes that require a quorum of administrator signatures which each node validates for itself. That last one means a rogue admin can't grant themselves a role because the network won't produce the signature, rather than because someone would notice in the audit log later.

What I actually measured

An idea like this is only worth anything if it survives contact with software nobody rewrote for it, so I put gateways in front of unmodified official images of SeaweedFS 3.97 and Gitea 1.24.7. The calling workload gets no credential at all. Not a blanked one, not a scoped one; the process environment is built from an allowlist and there is nothing in it. To do anything the caller requests authority for one exact action, and that authority is only minted after a policy running independently on every node agrees to it.

Against a live 5-node network on 28 August:

requesting storage.put for customer-files/alice/q3-summary.txt (48 bytes)
job job_e8952871...  advisory=AUTO  hash=81d57b3765e3b5...
fetching committed policy storage-auto
signing via ORK network (Policy:1, IMPLICIT)
ORK network returned a 64-byte Ed25519 signature
AuthorityReceipt auth_a1a93589f2c68331 minted
gateway -> 201 STORED IN SEAWEEDFS

The receipt lives about 45 seconds, covers one action, and is single-use. I then threw everything I could at that same real receipt:

attackresult
replay the identical requestAUTHORITY_ALREADY_CONSUMED
same authority, different bytesCONTENT_HASH_MISMATCH
same authority, DELETE instead of PUTACTION_MISMATCH
same authority, another user's objectRESOURCE_MISMATCH
delete the whole bucketACTION_MISMATCH
no authority at allNO_AUTHORITY
a stolen bearer tokenNO_AUTHORITY

The case I care about is a compromised calling workload, because that's the one that actually happens. What an attacker gets there is the ability to ask. They can request a 100MB upload, or a $50,000 refund, or a merge of a commit nobody reviewed, and the policy refuses each one on every node independently. Whoever owns that process ends up a petitioner instead of an owner, which is a smaller change than it sounds and also the only one I was after.

One earlier version of this had a real hole in it, which I'll describe because it's the kind of thing that's easy to leave out. Storage prefixes were keyed on the username, and the username came from the broker's say-so, while the network binds a different identifier entirely. A compromised broker could therefore write into someone else's prefix. Object keys are now derived from the network-bound identifier inside the policy contract itself, so the broker has nothing left to lie about on that path. It still chooses which action to describe to a human approver, though, which is a residual I haven't closed and probably can't.

The row that stays yellow

Gitea's API authenticates with a token, so the Git gateway holds one. Compromise that gateway and you get push and merge on that repository without going through any of this, with no reviewer, for as long as the token lives. It's row one of a credential inventory that lists every credential in the deployment and it renders yellow in the console, permanently, because a status board that only ever shows green isn't telling you anything.

There's a similar gap on the storage side. Objects in SeaweedFS sit there as plaintext in this demo, because what I was demonstrating is authority and not encryption at rest, so compromising the storage host reads everything. On that path, gateway compromise is roughly equivalent to storage compromise. What the design removes is the caller's standing authority and the stealable artifact, not the gateway's position on the network.

Where the whole approach stops helping

In February 2025 about $1.5 billion left Bybit, which the FBI attributed to North Korean actors. Bybit was using a multisig wallet and several independent people approved the transaction. It made no difference at all, because a compromised front end showed every one of them the same lie. They approved what they saw and signed something else.

Distributing signing authority is completely orthogonal to that attack. If the thing rendering the request is lying then a bigger quorum just collects more signatures on the wrong payload. The partial answer is pinning the code that draws the approval screen: the browser enclave ships with its own integrity="sha384-..." hash, so one changed byte means the browser refuses to execute it, and the hash is published so you can check that the window asking for your approval is the one that was specified without taking anyone's word for it. That's a genuine control aimed at exactly this failure. It also rests on browsers behaving correctly, which is a software property and not a mathematical one, and it can't make a person read carefully before clicking. What it buys is that a tampered approval screen doesn't get to load quietly.

There are five assumptions underneath all of this that don't go away, and the source material calls them irreducible, which I think is the right word:

  1. No more than 13 of a key's 20 nodes are colluding. This is the only genuinely cryptographic one.
  2. ECDLP, SHA-256 preimage resistance and EdDSA hold.
  3. Browsers enforce SRI, per the above.
  4. The node operators are actually independent. One operator secretly controlling fourteen nodes makes the threshold meaningless, and this is explicitly not Sybil-proof; the stated defence is that standing up fourteen independently-appearing organisations is economically prohibitive and operationally detectable. Independence also has to mean jurisdictional independence, since fourteen shares reachable by one court order is not fourteen parties.
  5. Payer node integrity, which is economic and bounded to a billing cycle. The Foundation currently runs the only payer cluster, which the sources describe as a maturity stage and not an architectural constraint. Read that how you like.

And the maturity gaps, which are in the sources and which I'd be embarrassed to omit. The double-blind threshold signing scheme is pending joint publication with RMIT and hasn't been independently audited. The custom curve, BEd255475, has not been independently published, and SafeCurves-equivalent analysis forthcoming is not analysis, and if I were reviewing this instead of building on it that's the first place I'd push. The architecture fails closed on purpose, prioritising confidentiality over availability, so it makes uptime worse and that's a real cost rather than a footnote. If an organisation permanently loses its admin quorum the key is locked and nobody can help, which argues for 2-of-5 over 3-of-3 and for actually testing the recovery path, since an untested one isn't one. Post-quantum specs aren't finalised. None of this protects availability: lose your backups and the data is still gone. Passwords are still passwords, and an origin-bound factor is still better against phishing. That's the actual lesson of 0ktapus: Twilio had a second factor and the code was relayed in real time, while Cloudflare's people were hit by the same kit in the same week and nothing happened, because their factor wouldn't function on the attacker's domain.

The question I can't answer yet is latency at scale. The sources flag it as unpublished and I don't have good numbers off a 5-node stack that would tell you anything about 20. If that's the first thing you'd want to know, it's the first thing I'd want to know too.

The threat model is written against the lowest bar I could think of, someone with a cloned repo and one leaked token, instead of the flattering nation-state version.

← all posts github / sashyo ↗