Developer guide · Zikaron

Security

This software keeps keys and evidence. This page states what it defends against, by what means, and the rules its maintainers follow.

Reporting a vulnerability

Report privately: email kaptonia@tuta.io, or message 05208a1c6901322420185a1f72ef691ac749fb86ca74c129cf65cb5ec8972a5f22 on Session. State the affected version, the steps to reproduce, what you observed and the impact as you see it. Until a fix is released, please keep the details private.

These classes are handled first: a signature that bypasses the user’s confirmation; a check that answers “All passed” to a forged input; local data or keys read while locked; two conforming implementations reaching different verdicts on the same input.

Threat model

AdversaryDefence
Someone who sits down at the machine while you are awayThe passcode gate; a lock after five wrong tries; auto-lock when idle; the master key cleared from memory on lock.
Someone who obtains the disk or a backup fileLedgers, settings, the queue, the identity registry, checked facts, the backup index, received grants, and the machine settings read once unlocked (the proxy’s user name and password among them) are stored encrypted, and the key store is encrypted separately under the passcode; file names, and the part of each file head that says which file it is, are computed with this machine’s keys, so matching files to entry and grant IDs takes the key. Plain are: machine.json, read before unlocking (the language and appearance alone), the read-only network list, the machine mark and writer marks, the blind adoption and handoff lists (the SHA-256 of a random salt and a root entry ID, naming no ledger, path or entry), the hashes of transactions the command line signed (cli-sent/, from which the transactions this machine sent can be looked up), and the exports meant for others (verification result files among them; an encrypted package’s index row adds only its one-time public key and records no recipient). While “With a credential” is on, the machine folder also holds an owner-only credential: a random key and the master key sealed under it. Whoever has that file can read and write the sealed data as the command line does, and sign and send as the identity, so it carries an expiry and goes when the switch goes off. What holds against offline guessing is the strength of the passcode and the backup password, and the scrypt (N=262144, r=8, p=1) every try has to run.
Someone who hands you forged recordsSignatures, the ledger’s chain and the records on the blockchain confirm one another, and verification gives a result for each check.
Someone who fabricates evidence after the factAn anchor gives an upper bound on time; a continuous ledger has every position already taken.
A node that liesFor your own ledger and grant checks, readings from several nodes are compared, by the kind of question, on the facts a verdict needs; answers that differ are a disagreement, and a reading needs the nodes to agree; the head is the lowest block every node has reached, and nodes more than 64 blocks apart are refused naming the node behind; only nodes answering for this chain count; Verify record and Look up a ledger ask one node per chain, and a single source is always marked as such. Before each read, a read-only network’s registry code hash is checked node by node, the network is read only on the nodes giving the pinned code, and a node giving other code makes the whole network sit out that read. Finding a recipient’s key from an address, one node’s transaction is enough: the key recovered from its signature must give the address asked about, so it proves itself; not anchored (ADDRESS_NOT_ANCHORED) is answered only when every node of every network read from the start block to the head holds no anchor that address signed, and a network not read through is an absent answer. With one node alone, logs and receipts cannot be cross-checked, and that is stated as it is. A gas estimate is a quantity each node works out, read by the largest (a higher limit costs no more), still bound by the 200,000 ceiling.
A web page or app that lures you into signingThe identity key is used in Zikaron alone, and the app signs in two of the four domains alone: zikaron/1 and zikaron/1-adoption.
Another user on this machineThe desktop’s local channel takes the same user alone: on macOS and Linux a socket file readable and writable by its owner alone, on Windows a named pipe open to this user on this machine alone; once connected, each end asks the system who the other is. The channel is open only while “Through the desktop app” is on and the desktop is unlocked and holds a data folder as its writer; locking, closing, quitting or turning on “With a credential” shuts it. It can write entries and put them on chain as the setting “Putting on chain from the command line” says, while the private keys and the passcode stay inside the desktop, and an attestation and a handover of the ledger are done on the desktop alone. The command-line credential is readable and writable by its owner alone.

Out of scope: malicious software running on an unlocked machine (it runs as you, and can ask the desktop through the channel to write entries); while “With a credential” is on, any program running as you (it can read the credential, read and write the sealed data even with the desktop locked, and spend this identity’s gas while putting on chain from the command line sends automatically); a connected AI app runs as you too, and through zikaron mcp does what the command line does, by the same two switches; memory reads; compromise of the operating system or hardware. For these, the software states its limits as they are: the card that turns on “With a credential” says the command line can spend gas, and the credential expires and can be switched off at any time.

Rules for keys

Deterministic signatures
RFC 6979 deterministic nonces with low-s values. One key signing the same bytes produces the same signature on every conforming tool.
Four independent domains
The domain literal is written inside the signed message, so a signature under one domain is valid in that domain alone. A new domain takes a literal wholly distinct from the existing ones.
Imported keys start read-only
An imported identity may have written a ledger elsewhere, so it stays read-only until the ledger here matches this key’s records on chain. A private key imported as the primary identity on first run exports its key file at the same time, and the identity is created once that file reads back and checks. A broken key file, or one whose parameters are out of bounds, is refused before any derivation, keeping the machine free.
Sign what the app assembled
The app signs content it assembled itself: in the window, what the user has seen on a confirm card; through the desktop’s channel, what this user’s command line asked for, carried out directly; with the credential, the command line signs with this identity’s key from the vault the entries and transactions it assembled itself and passed through the pre-write audit and the write gates. The signing entry point is open to such content alone.
Decrypting only agrees
Opening a record package encrypted to an identity here lends the private key to the sealed layer for one ECDH, giving one shared value; it signs nothing and writes no file. Each key store slot is named by its key’s address; a slot whose key gives another address is refused by the desktop and the command line alike, and the other slots still read.
Limits of encrypted record packages
The outer encryption covers delivery alone: once the recipient opens it, they can pass the plaintext on; it proves nothing of who sent it, since anyone holding the recipient’s public key can make one, and whether the records are genuine is judged as always by the signatures and the anchors on chain inside; there is no forward secrecy, so a recipient’s identity key leaking later opens every package sent to them before; the signing key is also the decryption key, because the key recovered from chain transactions is this key’s public key.
Keys stay in memory briefly
They are cleared after use. Recovery words once shown are wiped from memory when folded, on lock and on quit; a passcode typed in is wiped once handed over. Logs and error messages carry addresses alone.
Cryptography in one place
Calls for elliptic curves, hashes and key derivation are gathered in designated modules, and other code goes through them.

Rules for reading the chain

Failure is an absent answer
When a node is out of reach, readings differ or a scan is interrupted, the conclusion is recorded as absent, and the interface shows it so. A network fault and a forged record have to be told apart at a glance. A node answering a height that does not read is refused by name; a balance counts once a node has given it.
Green comes from a complete check
A transaction in a block counts as “Confirming”; the green lamp lights once the ledger check’s report has read it.
Leave the queue on a successful receipt
An entry leaves the queue once its receipt confirms success; entries whose send failed or timed out stay queued. Deleting a record that is still unsent also takes it out of the queue.
Check before resending
Entries already on chain are kept out of the queue, and before signing the sender asks the chain whether this key already anchored any of the hashes, sending nothing when one is there. A signed batch is recorded in the queue as submitted (its transaction hash and nonce) before it is broadcast, and after a restart the same transaction’s receipt is asked for by its hash, carrying on through a node’s brief outage. A broadcast is carried once, and “Resend with higher fees” is the user’s, on the desktop; anchor --ledger with the credential, at “Send automatically”, signs again as recorded, at its own nonce, only a batch no node’s pool holds whose nonce is still unused.
One nonce, paid once
A stuck batch is resent with higher fees at the user’s request: the same nonce, every fee at the current price and at least 10% above the old, the old and the new both watched, whichever enters a block first counting, and at most three times per batch. A batch no node pool holds is resent at the current price only while its nonce is unused; once another transaction has used it, the batch is void and its entries go back to the queue. When the command line holds the key itself, it records each transaction it signs in cli-sent/ before broadcasting; the same command run again first asks after those hashes, so one anchoring is paid for once.
Read the chain before writing out
Before anchoring, exporting a record kit, exporting a grant file or creating a credential, the chain is read once more; if it holds records this machine lacks, the step is refused with “Ledger is not up to date”.
Keep only what was checked
For an anchor several nodes confirmed alike with a decided verdict, the machine keeps its block time and verdict and reuses it on later reads; a replaced block or a differing log is asked about again. A reading from one node, or an unproven verdict, is never kept. “Check everything again” clears it at any time.
Certificates checked every time
For https nodes, the certificate chain and host name are checked on every connection; through a proxy, TLS stays end to end with the node and is checked against the node’s name. TLS is gathered in one place, zikaron-net, using rustls, with root certificates compiled into every program and kept apart from the system’s certificate store; the app and the command line take the same path. Through an HTTPS proxy, the leg to the proxy is TLS of its own, its certificate checked against the proxy’s name.
Bounded inputs
Each question is limited to 30 seconds from its start, at most 10 of them for connecting, and a response of at most 64 MiB; when a node’s address resolves to several IPs, each is tried. Record kits fetched from a publish address travel over https alone: 20 seconds and 32 MiB per file, at most 64 MiB per kit, and at most 3 same-origin redirects. On quitting the app, a question in flight is cut within a quarter of a second. A TLS handshake, a proxy’s reply or a node’s answer that trickles in a byte at a time is cut at the deadline all the same.
Waits by a table
A rate-limited node is asked again after 0.3 seconds and after 0.9 seconds; a server error, once more after 0.3 seconds; a read whose connection breaks before any answer arrives, once more on a new connection; a timeout or any other refusal is reported at once. Each kind is asked again at most twice, every pause under a second.
The proxy script runs in a sandbox
Following the system, the automatic configuration script the system names is fetched by the app directly, through no proxy: over https from anywhere, over http from this machine alone (a literal loopback address or localhost), never from file:; up to 5 redirects, each judged alike; at most 1 MiB, within 5 seconds. It runs in QuickJS, a fresh engine each run, holding the language’s own objects and the standard PAC functions with no network, files, modules or timers; each run has at most 2 seconds, 8 MiB of memory and a 512 KiB stack, and the name lookups it asks for are answered outside by the app, at most 1 second each. The script is given the node’s scheme, host and port alone, its path and query kept out. The ways a script names are tried in order, SOCKS as SOCKS5 and then SOCKS4. A script that cannot be fetched, run or read leaves the connection as with no script, its trouble named: PAC_UNFETCHED, PAC_SCHEME, PAC_REMOTE_HTTP, PAC_TOO_LARGE, PAC_FAILED, PAC_TIMEOUT, PAC_MEMORY, PAC_NO_FUNCTION, PAC_ANSWER.
Proxy credentials
An HTTP proxy gets Basic, a SOCKS5 proxy a user name and password, a SOCKS4 proxy a user ID alone; the password is kept in the encrypted machine settings and wiped from memory once written to the proxy, the settings page showing “Password set” alone. A proxy that asks for credentials not given, or refuses those given, is named: PROXY_CREDENTIALS_MISSING, PROXY_CREDENTIALS_REFUSED.

Privacy

  • The app’s network requests go to these destinations alone: the nodes the user configured, the publish address the user entered, the https addresses the user gives when verifying, the publish address carried in a grant file received (fetched again at each periodic re-verification), and the proxy auto-configuration script address the system settings name (fetched directly, through no proxy). Beyond these, the app makes zero update checks and sends zero telemetry.
  • Public nodes can see the addresses and contracts queried; the documents and the interface say so. When a record package is encrypted to an address, the nodes see the recipient address being looked up; given a public key, the chain is left unread.
  • The proxy follows the system’s settings by default: an automatic configuration script the system names is followed first, then the explicit proxies; the system’s automatic proxy discovery is left unread, and a system with only that switched on counts as going direct; a script on another machine over http is left unfetched and the connection goes as with no script, the settings page saying why. A custom proxy leaves the local network alone: private, link-local and .local nodes are reached straight, and a node on this machine always is. Every request’s User-Agent is the product name, ZIKARON, alone.
  • An access key inside a node’s address is hidden in every message, detail and log, whatever form the address is written in.
  • Text inside an entry travels with the ledger (record kits, grant files, mirrors) and stays readable for good; the documents say so plainly.
  • For short, guessable content, a fingerprint withstands a limited number of guesses; the documents remind users to add random content.
  • File names and paths at signing stay on the machine, stored apart from the ledger; record names are written into entries and are public with them.

Dependencies and the supply chain

  • Cargo.lock is committed, and release builds use --locked; the toolchain is pinned by rust-toolchain.toml.
  • Released binaries carry neutral path names alone, checked at build time by zikaron-pack no-paths. The Linux AppImage runtime is pinned by its bytes, and a downloaded one that differs stops the build. The proxy script engine QuickJS has its C sources built without assertions, as its own release builds are, so no build folder path lands in the binary; crates/zikaron-net/tests/engine_sources.rs checks it.
  • Dependencies are kept few; the cores and storage use the standard library wherever they can; the transport’s direct third-party dependencies are rustls (with ring as its cryptography backend), webpki-roots, and QuickJS for proxy auto-configuration scripts (through rquickjs, pinned at 0.14.0, the core engine alone); zikaron-os and zikaron-pack use the standard library alone. The sealed layer’s cryptography dependencies (chacha20poly1305, hkdf, hmac, sha2, scrypt, aes, ctr, and k256 for its curve arithmetic and ECDH alone) are used through zikaron-seal’s crypto module alone; the interface uses egui/eframe 0.34, and on macOS wgpu for Metal.
  • An upgrade of a cryptographic dependency gets its own pull request with a change note, and every conformance comparison is run again.
  • A new dependency comes with its purpose, maintenance status and licence.
  • Embedded fonts are released with their licence texts. Every platform’s package carries a third-party licence list generated from Cargo.lock, also shown in the app’s About; if the list cannot be generated, the build fails.

Rules for writing files

  • Write a temporary file, sync it to disk, then rename it into place; this works on FAT and exFAT volumes too. On Windows, a rename that meets a file another program (an antivirus, say) holds for a moment is retried within one second.
  • A local file that does not read is named and left as it is.
  • A write that fails or loses power half way removes its own temporary file; the writer opening a data folder sweeps away any left over, and the ledger reads with any of them there.
  • One writer per data folder: on one machine a kernel lock, let go the moment the process ends; while “With a credential” is on, the desktop and the command line each take it for one write and let it go after, reading again first if the other wrote, and a write that meets the other writing is left undone and says so. Across machines the writer mark, which makes the later machine read-only, and the command line refuses a mark naming another machine (E_HOME_OTHER_MACHINE). A data folder belonging to another identity or role is refused before it opens.
  • Existing files are always kept: a backup, record kit or grant file that meets a taken name saves under a numbered name and says where; key files and the command line stop at a taken name.
  • Read back and compare after writing, above all for key files and backups: report success once the read-back matches.
  • Changes such as restoring a backup or changing the primary identity complete as one step: either all of it takes effect or everything stays as it was. A batch of signatures stops at the first failure and keeps what it has signed.
  • Old data whose master key is gone, closed for good, is moved byte for byte into a set-aside directory.
  • Taking originals out is all or nothing: a name already in the chosen folder refuses the whole batch; a link is refused and never followed; everything is laid out in a staging folder inside the chosen folder, each checked against its fingerprint, then placed one by one, replacing nothing; any step failing takes back what was placed and clears the staging folder; nothing is ever written outside the chosen folder.
  • On restoring a backup, each member’s path is checked, and a member whose path climbs out of its place is refused. “Change data folder” takes an existing data folder or an empty one, and a move goes to a place outside the current folder.
  • Before the command line writes an entry, it audits “this ledger plus this entry” offline and refuses any write that would break the ledger (E_WOULD_BREAK); writing a desktop data folder it also passes the write gates, and a read-only mark, a broken ledger or a ledger handed off stops the write with nothing written (E_READ_ONLY, E_HANDED_OFF); a plain ledger folder the desktop has adopted is refused to every write (E_ADOPTED).
  • Key files, the command-line credential and ledger entry files are readable and writable by their owner alone: mode 0600 on macOS and Linux, owner-only on Windows, set by zikaron-os as the file is made. Replacing renames all go through zikaron-os.

Failures have to be visible

Whenever an operation falls through, the interface says so plainly. The costliest defect in software of this kind is a failure the user takes for a success: evidence believed to be on chain turns out, on the day it is needed, to be missing. When reviewing a pull request, ask of every new action: what does the user see when it fails?