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}
ComponentValue
methodHTTP method, e.g. GET or PUT
pathRequest path, e.g. /v1/secret/sk_a1b2c3d4e5
timestampUnix epoch seconds (e.g. 1711468800)
nonce16 random bytes, base64 encoded
bodyHashSHA-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:

HeaderValue
X-Machine-IdThe machine's UUID (from identity.json)
X-TimestampUnix epoch seconds
X-Nonce16 random bytes, base64 encoded
X-SignatureEd25519 signature of the payload, base64 encoded

The SDK sets all headers automatically. You do not need to construct them manually.

How Signing Works

ON THE MACHINE
Build the canonical payload
method:path:timestamp:nonce:bodyHash
bodyHash is the SHA-256 of the request body (empty string when there is no body).
Sign it Ed25519 private key
The signature covers the whole payload, so any change to method, path, time, nonce, or body breaks it. Produces X-Signature.
sent over HTTPS
X-Machine-IdX-TimestampX-NonceX-Signature
AT SIKKERKEY
Rebuild the same payload
from the received method, path, headers, and a fresh SHA-256 of the body.
Load the stored Ed25519 public key
32 bytes, registered for this machine at bootstrap.
Signature verifies against the payload?
no
401forged or tampered
yes
Timestamp fresh and nonce unused?
within 5 minutes of SikkerKey's clock, and this nonce has never been seen for this machine.
no
401stale or replayed
yes
Authentic request
proven to come from this machine, untampered and not replayed

The machine's Ed25519 private key (stored at ~/.sikkerkey/vaults/{vaultId}/private.pem) signs the payload:

  1. The SDK constructs the payload string from the request components
  2. The payload is encoded to UTF-8 bytes
  3. The bytes are signed with the Ed25519 private key
  4. 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)

  1. 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 Requests and a Retry-After header while it lasts. The per-machine lockout tracks failures by machine ID independently of source IP.
  2. Headers. All four authentication headers must be present, and the machine ID must parse as a UUID.
  3. 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.
  4. 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.
  5. 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 credentialAttacker has full access until rotatedAttacker needs the private key file, which never leaves the machine
Request forgeryAnyone with the token can forge requestsOnly the holder of the private key can sign valid requests
Replay attackSame token works indefinitelyEach request has a unique nonce and timestamp window
Database breachTokens or hashes in the database can be used or crackedOnly public keys in the database, which cannot forge signatures
RotationRequires distributing a new tokenRe-bootstrap generates a new keypair

Implementing a Custom Client

If you are not using one of the official SDKs, construct the request as follows:

  1. Generate 16 random bytes and base64 encode them (the nonce)
  2. Get the current Unix epoch in seconds (the timestamp)
  3. Compute the SHA-256 hex digest of the request body (or empty string for GET/DELETE)
  4. Concatenate: {METHOD}:{path}:{timestamp}:{nonce}:{bodyHash}
  5. Sign the concatenated string with your Ed25519 private key
  6. Base64 encode the 64-byte signature
  7. 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.