Ed25519 Signatures
How machines authenticate with SikkerKey using Ed25519 request signatures.
SikkerKey does not use bearer tokens or API keys for machine authentication. Every request is signed with the machine's Ed25519 private key. The signature proves the request was created by the machine, has not been tampered with, and is not a replay.
Signed Payload
The signed message is a colon-separated string:
{method}:{path}:{timestamp}:{nonce}:{bodyHash}
| Component | Value |
|---|---|
method | HTTP method, e.g. GET or PUT |
path | Request path, e.g. /v1/secret/sk_a1b2c3d4e5 |
timestamp | Unix epoch seconds (e.g. 1711468800) |
nonce | 16 random bytes, base64 encoded |
bodyHash | SHA-256 hex digest of the request body (empty string for bodyless requests) |
Example signed payload for a GET request:
GET:/v1/secret/sk_a1b2c3d4e5:1711468800:dGhpcyBpcyBhIG5vbmNl:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The last component is the SHA-256 of an empty string, since GET requests have no body.
Request Headers
Every authenticated request includes four headers:
| Header | Value |
|---|---|
X-Machine-Id | The machine's UUID (from identity.json) |
X-Timestamp | Unix epoch seconds |
X-Nonce | 16 random bytes, base64 encoded |
X-Signature | Ed25519 signature of the payload, base64 encoded |
The SDK sets all headers automatically. You do not need to construct them manually.
How Signing Works
X-Signature.The machine's Ed25519 private key (stored at ~/.sikkerkey/vaults/{vaultId}/private.pem) signs the payload:
- The SDK constructs the payload string from the request components
- The payload is encoded to UTF-8 bytes
- The bytes are signed with the Ed25519 private key
- The signature is base64 encoded and set as
X-Signature
The corresponding public key is stored in SikkerKey's database (registered during bootstrap). SikkerKey uses it to verify the signature.
How SikkerKey Verifies a Request
SikkerKey checks each request in two phases. Everything up to and including the signature is an authenticity check: a failure returns 401 Unauthorized and counts toward the brute-force lockout. Everything after it is a machine-state check, reached only once the caller has proven possession of the private key: a failure returns 403 Forbidden and does not count toward the lockout, so a machine that is merely disabled or off-window cannot lock itself, or the machines sharing its egress IP, out.
Authenticity checks (401, counted toward the lockout)
- Rate limit. The source IP, and then the machine ID, are checked against a lockout table: 3 failed attempts within 5 minutes trigger a 30-minute lockout for that key, and SikkerKey answers with
429 Too Many Requestsand aRetry-Afterheader while it lasts. The per-machine lockout tracks failures by machine ID independently of source IP. - Headers. All four authentication headers must be present, and the machine ID must parse as a UUID.
- Machine lookup. The machine ID must exist. This step only loads the machine's stored public key; its enabled, approved, and expiry state is not consulted yet.
- Query string. Machine-authenticated routes reject any non-empty query string. The signed payload covers only the path, so a query parameter would be unsigned and tamperable.
- Signature. SikkerKey rebuilds
{method}:{path}:{timestamp}:{nonce}:{bodyHash}(computing the SHA-256 hex of the body itself) and, within this step, checks in order: the timestamp is within 5 minutes of SikkerKey's clock, with 1 minute of forward skew allowed; the Ed25519 signature verifies against the stored public key; and the nonce has not been used before. The nonce is recorded only after the signature verifies, so an invalid signature cannot flood the nonce table.
An unknown machine, a bad signature, a stale timestamp, and a replayed nonce all return the same generic 401, so a caller holding only a machine ID cannot tell them apart or learn anything about the machine.
Machine-state checks (403, not counted toward the lockout)
Once the signature verifies, SikkerKey checks the following in order, denying with 403 Forbidden on the first failure:
- The source IP falls within the vault's IP allowlist, when one is enabled.
- The machine is enabled and approved.
- The machine has not expired (ephemeral and temporary machines only).
- Any per-machine guardrails on a temporary machine (IP, country, time of day) pass.
After verification
SikkerKey updates the machine's lastSeenAt and lastSeenIp, then hands off to the route handler (secret read, rotation, and so on). Every failed attempt is recorded in the audit log with the reason and source IP, while the caller always receives a generic message so no internal state (clock skew, nonce contents, machine status) leaks.
Why Ed25519
Ed25519 was chosen for machine authentication because:
- No shared secrets: the private key stays on the machine. SikkerKey only stores the public key. A database breach does not expose any material that can forge requests.
- Per-request proof: every request is independently signed. There is no session, no token to steal, and no credential to replay.
- Fast verification: Ed25519 signature verification is computationally inexpensive compared to RSA or ECDSA with equivalent security.
- Small keys: 32-byte public keys (44 characters base64). Stored compactly in the database.
Comparison with Bearer Tokens
| Bearer Token (API Key) | Ed25519 Signature | |
|---|---|---|
| Leaked credential | Attacker has full access until rotated | Attacker needs the private key file, which never leaves the machine |
| Request forgery | Anyone with the token can forge requests | Only the holder of the private key can sign valid requests |
| Replay attack | Same token works indefinitely | Each request has a unique nonce and timestamp window |
| Database breach | Tokens or hashes in the database can be used or cracked | Only public keys in the database, which cannot forge signatures |
| Rotation | Requires distributing a new token | Re-bootstrap generates a new keypair |
Implementing a Custom Client
If you are not using one of the official SDKs, construct the request as follows:
- Generate 16 random bytes and base64 encode them (the nonce)
- Get the current Unix epoch in seconds (the timestamp)
- Compute the SHA-256 hex digest of the request body (or empty string for GET/DELETE)
- Concatenate:
{METHOD}:{path}:{timestamp}:{nonce}:{bodyHash} - Sign the concatenated string with your Ed25519 private key
- Base64 encode the 64-byte signature
- Set the four headers and send the request
The private key is in PKCS#8 PEM format at the path specified in identity.json.
Query Strings Are Rejected
Machine-authenticated routes do not accept query strings. The signed payload covers only the path component, so any query parameters would be unsigned and tamperable. SikkerKey rejects any machine-authenticated request with a non-empty query string with 401 Unauthorized before signature verification runs.
Include all request parameters in the JSON body. The body hash is part of the signed payload, so body parameters are covered by the signature.