Tip You can manage keys in the TKeeper control plane available at
/ui.
TKeeper represents each logical key as a stable logicalId with an integer generation. Lifecycle operations are modeled as explicit state transitions and are enforced by permissions and safety rules.
Model
- logicalId: stable identifier for a logical key (example:
btc-hot-wallet). - generation: integer version of a logical key.
- current generation: the active generation used for new cryptographic output (signing/encryption).
TKeeper can maintain historical generations for verification/decryption and for operational rollback.
Lifecycle operations
Warning ALL TKeeper nodes should be available during
CREATE,ROTATEandREFRESHoperations.
At least THRESHOLD of nodes must be available forDESTROYoperation. If more than t but not all TKeeper instances were available forDESTROYoperation, you'll receive a warning in the Warning header and a299HTTP status code.
CREATE
Creates a new logical key using Distributed Key Generation (DKG). No single keeper generates or holds the full private key. The result is stored as generation 1 for the new logicalId.
API: POST /v1/keeper/dkg with mode = "CREATE"
Permission: tkeeper.dkg.create
ROTATE
Creates a new key generation and makes it the current generation for the same logicalId. Rotation is used for planned key replacement and cryptographic hygiene.
Operational effect:
- a new generation becomes current
- older generations become historical
API: POST /v1/keeper/dkg with mode = "ROTATE"
Permission: tkeeper.dkg.rotate
REFRESH
Replaces secret shares while preserving continuity of the logical key. Refresh is designed for cases where you want to invalidate potentially exposed shares without changing the operational identity of the key.
Tip Key refresh re-randomizes the secret shares across the keepers without changing the underlying private key.
The logical key identity stays the same, and the public key stays the same.
If an attacker previously stole one or more keeper shares, a refresh makes those stolen shares cryptographically useless: they no longer match the refreshed share set, so they cannot be combined to reach the threshold for signing or decryption.
API: POST /v1/keeper/dkg with mode = "REFRESH"
Permission: tkeeper.dkg.refresh
DESTROY
Permanently destroys key material for a specific generation of a logicalId. Destroy is intentionally constrained to reduce operational risk.
Safety rule:
- destroy is permitted only for generations that are at least two generations older than the current generation for the same
logicalId
Example: if current generation is3, generation1may be destroyed, but generation2must not be destroyed.
API: POST /v1/keeper/destroy
Permission: tkeeper.key.<keyId>.destroy
DKG API
DKG is the unified entry point for CREATE / ROTATE / REFRESH.
Endpoint
- POST
/v1/keeper/dkg
Request body
{
"keyId": "btc-hot-wallet",
"curve": "P256",
"mode": "ROTATE",
"policy": {
"apply": { "unit": "SECONDS", "notAfter": 1764417207 },
"process": { "unit": "SECONDS", "notAfter": 1767019207 },
"fourEye": {
"m": 2,
"n": 3,
"keys": [
{ "curve": "P256", "publicKey64": "base64(approver-public-key)" },
{ "curve": "SECP256K1", "publicKey64": "base64(approver-public-key)" },
{ "curve": "ED25519", "publicKey64": "base64(approver-public-key)" }
]
},
"allowHistoricalProcess": true
},
"approvals": {
"keeperId": 1,
"nonce": "ops-2026-02-12-001",
"timestamp": 1764417000123,
"proofs": [
{
"fingerprint": "base64(sha256(public-key-bytes))",
"signature64": "base64(signature-over-request-hash)"
}
]
},
"assetOwner": "treasury-team"
}
Fields:
| Field | Type | Required | Meaning |
|---|---|---|---|
keyId |
string | yes | Logical key identifier (logicalId). |
curve |
enum | yes | P256, ED25519, or SECP256K1. |
mode |
enum | yes | CREATE, ROTATE, REFRESH. |
policy |
object | no | Optional KeySetPolicy applied to this logical key. |
approvals |
object | no | Required for ROTATE and REFRESH when four-eye control is enabled for the key. |
assetOwner |
string | no | Optional operational note |
Response:
- returns HTTP 200 with no response body on success.
Note For
CREATE, you can setpolicy.fourEyeto register approver keys for future operations. ForROTATEandREFRESH, if four-eye control is active for the key,approvalsis required.
Public key retrieval
You can retrieve the public key for a logical key, optionally specifying a generation and tweak.
- GET
/v1/keeper/publicKey?keyId=<logicalId>&generation=<int?>&tweak=<string?> - Permission:
tkeeper.key.<keyId>.public
Query parameters:
| Param | Required | Meaning |
|---|---|---|
keyId |
yes | Logical key identifier. |
generation |
no | Specific generation to resolve. If omitted, current generation is used. |
tweak |
no | Optional tweak input for deterministic child-key derivation from the base key. |
Response:
{
"data64": "<base64-public-key-bytes>"
}
KeySetPolicy
KeySetPolicy is an optional policy attached to a logical key generation. It controls operational validity windows and optional external approval requirements:
- apply operations: create new artifacts using key material (signing, encryption)
- process operations: consume/validate existing artifacts (verification, decryption)
Policy fields
| Field | Meaning |
|---|---|
apply.notAfter |
Optional deadline after which apply operations are denied. |
process.notAfter |
Optional deadline after which process operations on historical generations are denied. |
fourEye |
Optional external approval policy for critical operations (dkg rotate/refresh, sign, ecies/decrypt, destroy). |
allowHistoricalProcess |
If false, denies process operations on historical generations regardless of deadlines. Default is true. |
Four-eye control (policy.fourEye)
For full four-eye details (policy model, approvals payload, keeperId, fingerprint rules, canonical signing payloads, replay trade-offs, and TTL behavior), see:
Timestamp format
Both apply and process use the NotAfter structure:
{ "unit": "MILLISECONDS", "notAfter": 1764417207123 }
unit:MILLISECONDSorSECONDSnotAfter: integer timestamp in the chosen unit (nullable)
Policy constraints
If both apply.notAfter and process.notAfter are provided:
process.notAftermust be later thanapply.notAfter, so data created before apply is blocked can still be verified/decrypted for a retention period.
For ROTATE and REFRESH:
KeySetPolicyis not inherited from the previous generation;- send
policyexplicitly in the request if you want to preserve or change any policy field (apply,process,fourEye,allowHistoricalProcess).
Operational warning:
- setting
process.notAfterorallowHistoricalProcess = falsecan make older signatures impossible to verify and older ciphertexts impossible to decrypt once the deadline passes (or immediately, if historical processing is disabled).
Key status semantics
Inventory and key listing endpoints report a status per key view.
| Status | Meaning |
|---|---|
ACTIVE |
Key is usable, subject to permissions and policy. |
APPLY_EXPIRED |
Apply is no longer allowed (for example, generation is historical or apply deadline passed), but process may still be allowed. |
EXPIRED |
Neither apply nor historical processing is allowed. |
DISABLED |
Key is administratively disabled (deny apply and process). |
DESTROYED |
Key generation material has been destroyed. |
Destroy API
Warning You can destroy a key only if it has at least two generations older than the current generation. (e.g. current generation is 5, then 1, 2, 3 are allowed to be destroyed)
Destroy is separate from DKG and targets a specific generation.
Endpoint
- POST
/v1/keeper/destroy
Request body
{
"keyId": "btc-hot-wallet",
"version": 1,
"approvals": {
"keeperId": 1,
"nonce": "ops-2026-02-12-002",
"timestamp": 1764418000123,
"proofs": [
{
"fingerprint": "base64(sha256(public-key-bytes))",
"signature64": "base64(signature-over-request-hash)"
}
]
}
}
Fields:
keyId: logical key identifierversion: generation to destroyapprovals: required when four-eye control is active for the key
Four-eye approvals for destroy
If four-eye control is active for the key, destroy requires approvals.
Canonical signed payload rules and examples are documented in Four-Eye Control.
Response:
- returns HTTP 200 with no response body on success.
Expiration
TKeeper exposes a key expiration query API that lets external systems poll for keysets that are about to expire (or already expired), without storing “alerts” inside TKeeper. You pull ExpireItem records and decide in your own system how to page, dedupe, escalate, and notify.
Model
ExpireItem represents a single expiration event for a keyset generation.
{
"type": "APPLY",
"logicalId": "my-key-id",
"generation": 2,
"expiresAt": 1766591893
}
Fields:
type:APPLYorPROCESSlogicalId: keyset logical idgeneration: keyset generation the expiration refers toexpiresAt: UNIX epoch seconds
Permissions
All endpoints require:
tkeeper.expired.view
If missing, the server returns ACCESS_DENIED.
Endpoints
List expiring keys by window or range
GET /v1/keeper/expires
Use this when you want a single endpoint that supports either:
- a window relative to “now”, or
- an explicit time range.
Query params:
type(required):applyorprocesslimit(optional): default100, clamped to[1..2000]cursor(optional): pagination cursor- Either:
windowSec(required if noto): window size in seconds, relative to UTCnow- or
to(required if nowindowSec): end epoch secondsfrom(optional): start epoch seconds, defaults to0
Validation rules:
- Missing
type->MISSING_EXPIRE_TYPE - Invalid
type(notapply/process) ->INVALID_EXPIRE_TYPE - If neither
windowSecnortois provided ->MISSING_WINDOW
Examples:
Window query (next 30 days apply expirations):
curl -G "$BASE_URL/v1/keeper/expires" \
-H "X-DEV-TOKEN: $TOKEN" \
--data-urlencode "type=apply" \
--data-urlencode "windowSec=2592000" \
--data-urlencode "limit=200"
Range query (process expirations between two timestamps):
curl -G "$BASE_URL/v1/keeper/expires" \
-H "X-DEV-TOKEN: $TOKEN" \
--data-urlencode "type=process" \
--data-urlencode "from=1764000000" \
--data-urlencode "to=1766592000" \
--data-urlencode "limit=200"
List apply expirations in a window
GET /v1/keeper/expires/apply
Query params:
windowSec(required)limit(optional): default100, clamped to[1..2000]cursor(optional)
Validation rules:
- Missing
windowSec->MISSING_WINDOW
Example:
curl -G "$BASE_URL/v1/keeper/expires/apply" \
-H "X-DEV-TOKEN: $TOKEN" \
--data-urlencode "windowSec=604800" \
--data-urlencode "limit=100"
List process expirations in a window
GET /v1/keeper/expires/process
Query params:
windowSec(required)limit(optional): default100, clamped to[1..2000]cursor(optional)
Validation rules:
- Missing
windowSec->MISSING_WINDOW
Example:
curl -G "$BASE_URL/v1/keeper/expires/process" \
-H "X-DEV-TOKEN: $TOKEN" \
--data-urlencode "windowSec=604800" \
--data-urlencode "limit=100"
List expired keys
GET /v1/keeper/expires/expired
This endpoint returns items that are already expired as of server now.
Query params:
type(required):applyorprocesslimit(optional): default100, clamped to[1..2000]cursor(optional)
Validation rules:
- Missing
type->MISSING_EXPIRE_TYPE - Invalid
type->INVALID_EXPIRE_TYPE
Example:
curl -G "$BASE_URL/v1/keeper/expires/expired" \
-H "X-DEV-TOKEN: $TOKEN" \
--data-urlencode "type=process" \
--data-urlencode "limit=200"
Pagination
All expiration endpoints return Page<ExpireItem, String>:
items: list ofExpireItemnext: cursor for the next page, ornullwhen done
Clients should keep requesting pages until next == null.
Note TKeeper does not track “notification stages” (30d/7d/1d). The service returns raw expiration times, and external systems decide their own thresholds, escalation logic, and idempotency tracking.