Yamale docs ← back to the site

The custodian: one share, and the decision to use it

The service that holds the operator's share of a consumer account. What it does, what it refuses, and — the longer list — what it deliberately does not do.

Status: the service is built and is not an account service. There is no enrolment over HTTP, no recovery workflow and no second factor. Read what it does not do before deploying it anywhere near a person's money.

Read first: mpc.md, for the three shares and why any two sign.

Not to be confused with custody.md, which is x/custody — issuing on-chain claims against outside assets somebody else is holding. This page is tools/custodian, a service holding one share of a consumer key. The two share a word and nothing else.


What it is actually protecting

Not the share.

The share alone is worthless, and saying so is not modesty — it is the property that makes the rest tractable. One share signs nothing. A breach of this service that took every share it holds would not yield a single signing key.

What the custodian protects is the decision. A person's phone can be stolen, and the thief then holds a device share and needs exactly one further thing to empty the account: a custodian willing to co-sign. A custodian that co-signs for whoever asks has turned a two-of-three into a one-of-one held by the attacker.

Everything below exists because of that sentence. The endpoints are deliberately boring and the refusals are the product.

What it refuses

Signing alone It holds one share of three. This is arithmetic, not configuration.
Importing any share but the custodian's "importing any other would give this service two, and two shares sign".
Starting with its secrets inside the store The sealing key and the pepper must live outside --dir, or the service exits.
A world-readable secret A key file with any group or other permission bit set is refused: chmod 600 it.
A pepper under 32 bytes A pepper an attacker can guess is a bare hash, which is the defect this replaces.
A password under 10 characters A length floor, and no composition rules — those measurably push people towards Passw0rd! and away from length.
Saying whether an account exists Wrong password and no such account are the same refusal, with the same status and the same wording, and the unknown case still spends the Argon2 time so the timing does not leak what the wording would not.
A second signature while one is in flight One open session per account, and the answer is busy rather than a queue. Concurrency here is the difference between paying somebody and paying them twice.
A frozen account A freeze stops co-signing entirely, and it is what a reported theft turns on.
A stale session Two minutes. An abandoned session holds a party mid-protocol with the share loaded, and a service accumulating those is one whose memory is a pile of live key material waiting for a heap dump.
Replaying a finished session Once a signature exists the session is done and closed, on the happy path too.
A message claiming to be from itself The only peer in a signing committee is the device. Anything else is a bug or somebody probing.
An amount over the second-factor threshold Refused outright, because second-factor enrolment is not built. A service that skips a check because the feature behind it is unfinished is a service that ships with the check missing.

Running it

Two deployments, never one. Enrolment needs three parties and the device is only one of them, so this binary runs twice — once as the custodian, once as the recovery holder — with separate sealing keys, separate directories and, if the deployment is serious, separate operators:

custodian --role custodian \
          --dir /var/lib/yamale/custodian \
          --sealing-key-file /etc/yamale/custodian.key \
          --pepper-file /etc/yamale/custodian.pepper \
          --listen 127.0.0.1:8099 \
          --second-factor-above 0

custodian --role recovery \
          --dir /var/lib/yamale/recovery \
          --sealing-key-file /etc/yamale/recovery.key \
          --pepper-file /etc/yamale/recovery.pepper \
          --listen 127.0.0.1:8100

One process able to hold both shares would be one process able to sign, and the whole design is the claim that no such process exists. So the store enforces it rather than trusting the configuration: it is constructed for exactly one role, refuses to write a share of any other, and refuses to hand back one it finds on disk — which is what catches a directory shared by mistake or a backup restored to the wrong service. --role device is refused outright, because the device's share belongs on the device.

The sealing key and the pepper must not live in --dir, and the service refuses to start if either does. That is the whole point of both: a stolen directory yields ciphertext and a set of unguessable blind indexes, and neither can be attacked without a file that was never in it.

The reason it is checked rather than documented: "back up /var/lib/yamale/custodian" is the one instruction an operator will certainly follow, and if the sealing key is in there the backup is a plaintext copy of every share the service holds.

--import still exists, for moving an account produced by a ceremony:

custodian --dir ... --sealing-key-file ... --pepper-file ... \
          --import ./account/custodian.json \
          --import-email person@example.org \
          --import-password '...'

It prints the account's bech32 address and exits, and refuses a share that is not this deployment's role.

Enrolment

A member of the public creating an account, without any single service ever holding two shares.

The device coordinates. It runs its own key-generation party — in the browser, via mpc/wasm — and speaks to both services. Each service runs its own party, computes its own share, and transmits none of it. The device is the only participant that talks to both, and the only one that ever holds its own share.

POST /v1/enrol/start     email + password         -> session, first messages
POST /v1/enrol/message   session + one message    -> replies, and whether done
POST /v1/enrol/finish    session + the address    -> committed, or refused

/v1/enrol/message with no message is a poll, and it is not optional: a round's messages can become available after the response that triggered them was already written, and a client that never asks again stalls one round short with nothing logged. Poll until done is true for every party — the device knowing its own share says nothing about whether its peers know theirs, and /finish refuses a generation that has not completed.

What this can and cannot verify

It cannot verify that the person enrolling is who they say. Nobody can, over HTTP, without a document or an agent, and pretending otherwise would be worse than saying so. Identity belongs to enrolment policy — an agent network, a document check, an invitation — and is deliberately not decided here.

What it does verify:

Nothing is written to disk until all of that holds. A generation that is abandoned, fails, or is finished with a mismatched address leaves no account behind — the password verifier is computed at start and held in memory until finish.

The pre-parameter pool

A party's Paillier key needs two safe primes and finding them takes minutes. Generating them inside the request would give a person a four-minute spinner and give an attacker an unauthenticated endpoint costing minutes of CPU per call — a handful of requests pins every core and nobody can enrol or sign.

So they are made in advance, in the background, and enrolment either takes one from the shelf immediately or is refused immediately with 503. Both answers are fast, and "come back in a minute" is a far better failure than a service that becomes slow for everybody.

Do not make Take block when the shelf is empty. It is the obvious improvement and it restores the whole problem behind a nicer error message: an unbounded queue whose length is set by whoever is attacking. --preparam-pool sets how many are kept ready; the default is 4.

Recovery

The path these systems are actually robbed through, so this is the most conservative thing in the repository. Support-desk social engineering is the most reliable attack on consumer finance there is, and it needs no cryptographic weakness at all.

POST /v1/recovery/initiate    operator, team, proof  -> the clock starts
POST /v1/recovery/approve     a second operator, a different team
POST /v1/recovery/complete    after 72 hours and two approvals
POST /v1/recovery/cancel      anybody, any time before completion
GET  /v1/recovery/statistics  counts and durations, never who

These are operator endpoints, not customer ones, and that asymmetry is deliberate: a customer cannot recover their own account, because an attacker holding a customer's email could then do it too. What a customer can do is cancel, which needs no authority at all — the worst a malicious cancellation achieves is that a real recovery has to start again.

The five rules, and why each one is load-bearing

Two approvers, from different teams, neither of them the initiator. One approver is one person to deceive. Two from the same team are two people with one manager and one set of pressures, so the same team as the initiator is excluded too.

A 72-hour delay. This is the attacker's real cost: they must keep the owner unaware for three days. Shorten it and a weekend covers it. It is a constant, not a setting.

Notice at initiation — and if notice fails, nothing starts. The delay protects an account only if its owner is told the clock is running. A recovery nobody was notified of is not a slow recovery, it is a silent one. So --notify-command is required, and a service without one refuses to start a recovery rather than running the process correctly and quietly, which is the single most dangerous configuration this code can be in.

Outbound payments frozen for 24 hours afterwards. Recovery restores access, not immediate spending. Somebody who did get through the process wrongly still cannot convert it to money that afternoon, and the owner gets another window to object. This is checked on every signature, not at sign-in, so a session opened before the freeze cannot outlive it.

A recorded standard of proof. The service cannot check "they named their last three payments" — no service can. What it can do is refuse to proceed without one and make it a thing a named operator put their name to. Not name, date of birth and address: that is precisely what social engineering runs on.

What completing a recovery does not do

It does not re-key anything. Completion marks the account recoverable and freezes outbound spending; the actual reshare — mpc.Reshare, which produces new shares under the same public key, so the address and the x/alias identifier survive — is driven by the device once the customer is back. Putting the reshare in here would mean this service generating a share on behalf of somebody who was not present, which is exactly the property the whole design refuses.

Published in aggregate

GET /v1/recovery/statistics returns counts, the median hours from initiation to completion, and how many completed in the last 30 days. No email, no blind index, no operator name. From the design: an unusual rate should be visible without exposing who — and a number nobody looks at protects nobody, so it is an endpoint rather than a log line.

Notice, and one real constraint

--notify-command is run as <command> <event> <address> [detail]. A command rather than built-in SMTP, because every deployment already has a way to send mail and none of them agree on it — and because a subprocess is auditable by somebody who does not read Go.

The command is given the account's chain address, not their email. This service stores a blind index and never the address itself, which is what makes a stolen store much less useful. So the deployment must resolve address to person its own way. That is a genuine constraint rather than an oversight, and it is the price of the store not holding a list of everybody's email.

The subprocess does not inherit this process's environment, because this process's environment is where the paths to the sealing key and the pepper are.

The endpoints

POST /v1/sign/start authenticate, open a session, return the custodian's first protocol messages
POST /v1/sign/message feed one device message in, get the custodian's replies
POST /v1/sign/result the signature, or {"pending": true}
POST /v1/freeze stop this account co-signing
GET /v1/health account count, open sessions, uptime

Five, and one of them is a health check. The service is small on purpose.

/v1/health reports what it holds as a sentence rather than a number: "one share per account, which signs nothing alone".

It signs 32 bytes it cannot read

/v1/sign/start takes a digest. The custodian does not build the transaction and does not parse it, and it has no way to recover what those bytes commit to.

This is a limitation and not a design win, and it is stated that way in the code. A custodian that cannot read what it signs cannot refuse a payment on its merits — it can only refuse the signer. It can tell a wrong password from a right one and a frozen account from a live one; it cannot tell a rent payment from an account being drained.

The amount is passed alongside the digest, for the second-factor rule, and it is asserted by the caller — which means a thief asserts zero. That is named honestly in the request type rather than dressed up. Making the amount visible to the custodian, so the threshold applies to something the custodian can verify, is the next piece and it is not built.

The freeze is judged differently from signing, on purpose

/v1/freeze needs only the password. That is a deliberately different bar from signing, and the reasoning is worth keeping: somebody reporting a stolen phone is often distressed, sometimes on a borrowed device, and rarely able to produce a second factor that was on the phone.

Making a freeze hard to request protects nobody. The cost of a false freeze is an inconvenience; the cost of a slow one is the account.

A freeze is not a deletion. An account that vanishes takes its own audit trail with it.

The second factor is demanded at signing, not at login

The rule most systems get wrong: gating the login and not the payment protects the session rather than the money. An attacker holding the password and the device share does not need to log in again — they need to sign.

So the threshold is on the amount, at the moment of signing, and not on the session's age. --second-factor-above 0 means every signature needs one, which is a legitimate setting for an institution and a miserable one for somebody buying bread.

Operating it

GET /v1/health is written for whoever is on call, and answers three questions nothing else in this service would:

{
  "role": "custodian",
  "accounts": 0,
  "preparams_ready": 1,
  "enrolment": "ready",
  "recovery": "disabled: no notify command, and a recovery nobody is told about is worse than none",
  "open_sessions": 0,
  "open_enrolments": 0
}

enrolment is the one to alert on. An empty pre-parameter pool means new accounts have been refused for as long as it has been empty — and everything else looks perfectly healthy while it happens. Existing accounts sign, the store is fine, and the only symptom is people who cannot sign up, which nobody operating the service ever sees.

recovery says whether a recovery could run at all. A deployment without --notify-command refuses to start one, by design. Better read on a health check than discovered the first time somebody needs one.

role catches the misconfiguration that produces no error: two deployments both set to custodian will happily enrol an account, and it will have no recovery share, and nothing will say so until the day somebody needs it. The enrolment client checks this too.

Two things learned deploying it, so they are not learned twice

ReadWritePaths does not expand EnvironmentFile variables. systemd resolves the sandbox before the environment file is read, so ${CUSTODIAN_DIR} silently becomes nothing and the service starts with its own store read-only. It then fails at the first write with read-only file system, which reads like a disk fault rather than a unit fault. The unit uses a literal parent path.

MemoryDenyWriteExecute cannot be used, and it is exactly the flag you want on a process holding key material. github.com/cloudwego/base64x, pulled in transitively, writes AVX2 dispatch code at init and executes it, so the process panics with permission denied before it opens a socket. The unit says so rather than simply omitting it — setting it, seeing the crash, and quietly dropping it is how that becomes folklore.

Timeouts, which are not one number

Signing is milliseconds of arithmetic. One round of key generation is tens of seconds of Paillier and range-proof verification, inside the handler. Go's Server timeouts are per-connection and global, so a single WriteTimeout that suits signing cuts key generation mid-round — and because the handler has not failed, nothing is logged and the client sees a bare EOF with no status and no message. That is how the first live enrolment died.

The socket bound therefore fits the slowest legitimate handler, and the fast routes get a tight bound back individually through http.TimeoutHandler, which answers 503 rather than dropping the connection. A client driving enrolment needs a matching allowance: 60 seconds reproduces the same symptom from the other end.

What a breach yields

Stated first because it is the question an auditor asks first.

What leaks What the attacker gets
The store alone Ciphertext, and a set of blind indexes. No email addresses, no names, no signing key.
The store and the sealing key Every custodian share. Still not a key: they then need a device share per account, one at a time, from the people holding them.
The store and the pepper The ability to test a guess at which email addresses are enrolled.

Encrypting at rest is not the last line of defence here — the threshold is. What it buys is that a stolen disk is not also a stolen queue of half-compromised accounts.

The Account record is worth reading for what is not in it: no name, no email, no phone, no document number. The custodian needs to find an account and decide whether to co-sign; it does not need to know who anybody is, and personal data it never holds is data it cannot leak or be compelled to produce.

Passwords are hashed, never encrypted — Argon2id, per-user salt, tuned to roughly 90 ms rather than copied from an example. Encryption is reversible, so a breach that takes the key takes every password, and people reuse passwords, so that breach spends beyond this system entirely.

Verification is constant-time. A comparison that returns early tells an attacker how many leading bytes they got right, and enough of those answers is the hash.

The blind index, and the defect this fixes

An account is found by email without the email being stored. The wrong answers, in the order people reach for them:

  1. Store the email. A dump is then a mailing list of everybody who uses a national payments system, which is a different kind of disaster from a financial one.
  2. Encrypt it deterministically. Equal emails produce equal ciphertext, so the column is a frequency-analysable index of the same thing.
  3. Hash it. This is the one that feels safe and is not. Email addresses are low-entropy and enumerable, so an attacker with a list and a CPU tests every guess offline and recovers exactly the membership the hash was meant to hide.

The answer is a keyed hash whose pepper lives outside the store. A dump alone then yields neither the addresses nor any way to test a guess at them, because testing requires a file that was never in the thing that leaked.

clients/app does number 3 today. emailKey() is a bare SHA-256 of the address and the code calls the result a blind index; it is not one. Local-only storage makes it less severe than the same mistake server-side — it does not make the claim true. The custodian is the first place in this codebase that does it correctly, and the divergence is recorded in gaps.md.

Emails are lower-cased and trimmed before hashing. Alice@Example.COM and alice@example.com are one person and two keys, and the second one silently becomes an account nobody can sign in to.

What it does not do

This list is longer than the feature list, and that is the honest shape of it.

Where else to read