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

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). The server uses it to verify the signature.

Server Verification

The server verifies each request in this order:

1. Rate Limit Check

The source IP is checked against a lockout table. 3 failed authentication attempts within 5 minutes triggers a 30-minute lockout for that IP. A separate per-machine lockout tracks failures by machine ID independently of source IP. The server responds with 429 Too Many Requests and a Retry-After header during lockout.

2. Header Presence

All four authentication headers must be present. Missing headers result in 401 Unauthorized.

3. Machine Lookup

The machine ID must exist in the database. The machine must be:

  • Enabled: a disabled machine gets 403 Forbidden
  • Approved: a pending machine gets 403 Forbidden

4. Signature Verification

The server reconstructs the signed payload from the request:

  1. Reads method, path, timestamp, nonce from the request/headers
  2. Computes the SHA-256 hex digest of the request body
  3. Concatenates them as {method}:{path}:{timestamp}:{nonce}:{bodyHash}
  4. Decodes the machine's stored public key from base64 (32 bytes, raw Ed25519)
  5. Verifies the Ed25519 signature against the reconstructed payload

If the signature does not match, the request is rejected with 401 Unauthorized.

Signature verification happens before nonce consumption. This means an invalid signature cannot flood the nonce table.

5. Timestamp Window

The timestamp must be within 5 minutes of server time. A tolerance of 1 minute is allowed for forward clock skew (machine clock slightly ahead of server). Requests outside this window are rejected.

6. Nonce Uniqueness

The nonce, scoped to the machine ID, must not have been used before. Nonces are persisted in the database with the primary key set to {machineId}:{nonce}. A duplicate nonce causes a unique constraint violation, which the server treats as a replay attempt.

Nonce tracking survives server restarts. Expired nonces (older than 6 minutes) are cleaned up automatically every 60 seconds.

7. Account Status

The vault owner's account must be active. If the account is suspended, the request is rejected with 403 Forbidden. This check runs after signature verification so no computation is wasted on suspended accounts, but the caller receives a generic error to avoid leaking account state.

After Verification

If all checks pass, the server updates the machine's lastSeenAt timestamp and lastSeenIp, then proceeds to the route handler (secret read, rotation, etc.).

Every failed authentication attempt is recorded in the audit log with the failure reason and source IP. Failed attempts count against both the source IP and the machine ID lockout.

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.