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
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). 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:
- Reads
method,path,timestamp,noncefrom the request/headers - Computes the SHA-256 hex digest of the request body
- Concatenates them as
{method}:{path}:{timestamp}:{nonce}:{bodyHash} - Decodes the machine's stored public key from base64 (32 bytes, raw Ed25519)
- 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 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.