Four-eye control can be unconditional at the key level or selected by an authority policy for a particular typed signing intent. In both cases, TKeeper requires the configured number of distinct approver signatures before continuing.
Key policy shape
{
"fourEye": {
"mode": "STRICT",
"m": 2,
"n": 3,
"keys": [
{
"algorithm": "SECP256K1",
"publicKey64": "..."
},
{
"algorithm": "P256",
"publicKey64": "..."
},
{
"algorithm": "ED25519",
"publicKey64": "..."
}
]
}
}
Rules:
modeisSTRICTorLENIENT; omitted values default toSTRICTmmust be at least2mcannot be greater thannkeys.sizemust equaln- duplicate approver keys are rejected
- approver public keys must decode under the declared algorithm
- approver algorithms must be present in the runtime artifact; ECC provides
SECP256K1,P256, andED25519, while the optional PQC platform addsMLDSA44,MLDSA65, andMLDSA87
STRICT preserves the original behavior: approvals are required for every operation protected by the key policy, including signing, decrypting, rotating, refreshing, and destroying a generation.
LENIENT protects key lifecycle changes: ROTATE, REFRESH, and generation destruction. Signing and decryption proceed without key-bound approvals. Initial CREATE has no stored key policy to enforce; a fourEye policy supplied during creation protects later operations. Authentication, permissions, authority policies, and audit checks still apply.
Policy-driven approvals
An authority manifest can require approvals only when a particular allow rule matches. Approver public keys are declared once under policy.approvers; each rule selects a threshold and a set of approver ids:
policy:
id: payment-policy
fallback: DENY
approvers:
operator-a:
algorithm: SECP256K1
publicKey64: "..."
operator-b:
algorithm: P256
publicKey64: "..."
compliance:
algorithm: ED25519
publicKey64: "..."
allow:
- id: approve-payment
where:
- "purpose == 'payment'"
approvals:
threshold: 2
approvers: [operator-a, operator-b]
- id: compliance-review
where:
- "purpose == 'payment'"
approvals:
threshold: 1
approvers: [compliance]
If both rules match, both approval groups must be satisfied. Requirements from a key-level fourEye policy are cumulative with authority-policy requirements; one approval payload can carry proofs for every group.
An ALLOW_WITH_REQUIREMENTS fallback uses the same model:
policy:
id: guarded-fallback
fallback: ALLOW_WITH_REQUIREMENTS
approvers:
operator:
algorithm: ED25519
publicKey64: "..."
fallbackApprovals:
threshold: 1
approvers: [operator]
For an authority-policy challenge, TKeeper returns APPROVAL_REQUIRED with the policyId, rule or fallback source, and threshold for every required group. The public keys remain in the authority manifest. Sign the canonical request hash with enough keys from every group, attach all proofs to the same request, and resubmit it unchanged.
Policy-driven approvals apply to typed signing through the selected authority. Arbitrary signing does not evaluate an authority policy. The audit event retains the ALLOW_WITH_REQUIREMENTS decision and its requirements after successful approval.
Approval model
Approvers sign a hash of the exact operation body. TKeeper verifies the submitted proofs before continuing to signing, DKG, destroy, or decrypt.
Approval payload:
{
"approvals": {
"keeperId": 1,
"nonce": "unique-nonce",
"timestamp": 1760000000000,
"proofs": [
{
"fingerprint": "...",
"signature64": "..."
}
]
}
}
At the coordinator boundary, the nonce is one-time and is consumed only after enough signatures verify. Consumed nonces are persisted in RocksDB for keeper.approval.ttl, so a coordinator restart does not reopen the replay window. The timestamp must not be in the future and must fit the same TTL.
Threshold protocol retries reuse the same approval. Non-coordinator peers therefore verify its signatures, approved request fields, and TTL without independently consuming the nonce. If the coordinator is compromised, it can replay an approval with those same fields only while it remains fresh; see the threat model.
The coordinator peer id in approvals.keeperId must match the peer coordinating the operation.
Signature algorithms
| Approver key | Approval signature |
|---|---|
SECP256K1 |
ECDSA |
P256 |
ECDSA |
ED25519 |
EdDSA |
MLDSA44 |
ML-DSA |
MLDSA65 |
ML-DSA |
MLDSA87 |
ML-DSA |
The approver fingerprint is:
base64(sha256(encoded-public-key))
Building hashForSigning
One format for key-bound and policy-bound approvals
Key-bound Four-Eye loads approval keys from the policy stored with the selected key generation. Policy-bound Four-Eye loads approval groups from every matching authority allow rule. The guard merges both sources before verification.
All approvers sign one hashForSigning. All proofs travel in one approvals.proofs array with one keeperId, nonce, and timestamp. Combined enforcement requires every key-bound and policy-bound group; it creates no second request or hash.
Policy-bound groups currently apply to typed Sign requests. Key-bound groups apply according to their mode:
| Key policy mode | Protected operations |
|---|---|
STRICT |
Sign, ROTATE, REFRESH, ECIES Decrypt, and Destroy |
LENIENT |
ROTATE, REFRESH, and Destroy |
Initial CREATE has no stored key policy. A fourEye policy carried by CREATE starts protecting the key after generation 1 becomes active.
A policy-only request with an empty proof list returns APPROVAL_REQUIRED and the required policyId, rule or fallback source, and threshold. The caller supplies the approval envelope and calculates the hash.
Request transformation
Add this field to the exact operation request:
{
"approvals": {
"keeperId": 1,
"nonce": "018f-example-unique-nonce",
"timestamp": 1760000000000,
"proofs": []
}
}
Build the hash preimage with this transformation:
- Copy every operation field except the top-level
approvalsfield. - Copy
approvals.keeperId,approvals.nonce, andapprovals.timestampinto the preimage root. - Leave
approvals.proofsoutside the preimage. - Recursively canonicalize the preimage and hash its UTF-8 bytes with SHA-256.
- Sign the resulting 32 bytes with enough keys from every required group.
- Add the proofs to the original request and submit it with the same operation fields, keeper id, nonce, and timestamp.
Each proof contains:
fingerprint = base64(sha256(encoded-public-key))
signature64 = base64(signature-bytes)
ECDSA signatures use compact r || s bytes with an optional recovery-id byte. Ed25519 uses its 64-byte detached signature. ML-DSA uses the encoded detached signature for the declared parameter set.
The Java SDK models Sign, Generate, Decrypt, and KeyDestroyReference implement Approvable:
var approvals = Approvals.template(
coordinatorPeerId,
UUID.randomUUID().toString(),
Instant.now().toEpochMilli()
);
var request = Sign.builder(keyId, command)
.approvals(approvals)
.build();
byte[] hashForSigning = request.hashForSigning();
byte[] signatureBytes = approvalSigner.signDigest(hashForSigning);
request.addProof(new Approvals.Proof(
approverFingerprint64,
Base64.getEncoder().encodeToString(signatureBytes)
));
client.signature().sign(request);
approvalSigner denotes the application's HSM, wallet, or external approval service.
Sign
The same request supports key-bound approvals, policy-bound approvals, or both:
{
"keyId": "payments-key",
"command": {
"type": "custom",
"authorityId": "payments",
"artifact": {
"scheme": "ECDSA",
"hash": "SHA256",
"typed": {
"purpose": "payment",
"amount": "1000"
}
}
},
"approvals": {
"keeperId": 1,
"nonce": "018f-example-unique-nonce",
"timestamp": 1760000000000,
"proofs": []
}
}
Hash preimage:
{
"keyId": "payments-key",
"command": {
"type": "custom",
"authorityId": "payments",
"artifact": {
"scheme": "ECDSA",
"hash": "SHA256",
"typed": {
"purpose": "payment",
"amount": "1000"
}
}
},
"keeperId": 1,
"nonce": "018f-example-unique-nonce",
"timestamp": 1760000000000
}
Copy a non-null request tweak into the preimage root.
DKG: CREATE, ROTATE, and REFRESH
Generate.hashForSigning() covers all three modes. The mode string belongs to the signed data, so a proof for one mode fails for the other two.
Example ROTATE request:
{
"keyId": "lifecycle-key",
"algorithm": "SECP256K1",
"authorities": [
{"id": "arbitrary"}
],
"mode": "ROTATE",
"approvals": {
"keeperId": 1,
"nonce": "rotate-unique-nonce",
"timestamp": 1760000000000,
"proofs": []
}
}
Hash preimage:
{
"keyId": "lifecycle-key",
"algorithm": "SECP256K1",
"authorities": [
{"id": "arbitrary"}
],
"mode": "ROTATE",
"keeperId": 1,
"nonce": "rotate-unique-nonce",
"timestamp": 1760000000000
}
Optional request fields policy and assetOwner enter the preimage recursively. This binds approval to the resulting generation's authorities, policy, and owner.
| Mode | Approval source and result |
|---|---|
CREATE |
No previous generation supplies a key-bound group. The request's policy and assetOwner become generation 1 metadata. |
ROTATE |
The active generation's policy authorizes replacement. The request's optional policy and assetOwner become metadata for the new key material. |
REFRESH |
The active generation's policy authorizes refreshed shares. The request's optional policy and assetOwner become metadata for the refreshed generation. |
For Java SDK requests, pass KeyGenMode.CREATE, KeyGenMode.ROTATE, or KeyGenMode.REFRESH to Generate.builder(...), attach .approvals(approvals), and call hashForSigning() before adding proofs.
ECIES decrypt
ECIES encryption uses public key material and carries no approvals. Decrypt uses the key-bound policy stored with the requested generation. An omitted generation selects the active generation and leaves generation out of the preimage.
Request:
{
"keyId": "ecies-key",
"algorithm": "AES_GCM",
"generation": 3,
"ciphertext64": "AQIDBA==",
"tweak": "invoice-42",
"approvals": {
"keeperId": 1,
"nonce": "decrypt-unique-nonce",
"timestamp": 1760000000000,
"proofs": []
}
}
Hash preimage:
{
"keyId": "ecies-key",
"algorithm": "AES_GCM",
"generation": 3,
"ciphertext64": "AQIDBA==",
"tweak": "invoice-42",
"keeperId": 1,
"nonce": "decrypt-unique-nonce",
"timestamp": 1760000000000
}
The server resolves an omitted algorithm to AES_GCM and includes that value in the preimage. Clients should send algorithm explicitly. A non-null tweak and an explicit generation enter the preimage.
For the Java SDK, build Decrypt with .generation(...), .tweak(...), and .approvals(approvals), then call hashForSigning().
Destroy
Destroy uses the key-bound policy stored with the target generation. Both STRICT and LENIENT protect this operation.
Request:
{
"keyId": "lifecycle-key",
"generation": 1,
"approvals": {
"keeperId": 1,
"nonce": "destroy-unique-nonce",
"timestamp": 1760000000000,
"proofs": []
}
}
Hash preimage:
{
"keyId": "lifecycle-key",
"generation": 1,
"keeperId": 1,
"nonce": "destroy-unique-nonce",
"timestamp": 1760000000000
}
Java SDK clients use new KeyDestroyReference(keyId, generation, approvals), call hashForSigning(), add proofs, and pass the request to client.destroy().destroy(request).
Canonical encoding
Canonicalization rules:
- serialize compact UTF-8 JSON with no insignificant whitespace
- omit fields whose value is
null - sort JSON object field names lexicographically at every object level
- sort map entries by key
- preserve array order
- canonicalize objects inside arrays recursively
- preserve scalar types and string bytes, including Base64 text, enum names, nonce, and tweak
hashForSigning = SHA-256(UTF-8(canonical-json))
Feed those 32 bytes directly to the approver algorithm. Hexadecimal and Base64 encodings serve display and transport. ECDSA consumes the supplied digest; Ed25519 and ML-DSA consume the 32 bytes as their message.
Canonical Sign vector:
{"command":{"artifact":{"hash":"SHA256","scheme":"ECDSA","typed":{"amount":"1000","purpose":"payment"}},"authorityId":"payments","type":"custom"},"keeperId":1,"keyId":"payments-key","nonce":"018f-example-unique-nonce","timestamp":1760000000000}
Expected SHA-256: ad0a5caa1cf84dcfb4b0012ac5af306ebb8938b1cafe4f0ab0d4e6232b135e9b.
Security notes
- Four-eye approvals do not replace TKeeper authentication or permissions.
- Approver keys should be stored separately from TKeeper peers.
- Any change to the approved request body requires new approvals.
- Approval signatures are only as trustworthy as approver key custody.
- Approval tooling should render the canonical operation from the signed fields. A trusted human summary that is not bound to the approval hash can mislead the approver.
- Nonce uniqueness prevents approval reuse inside TKeeper; downstream replay rules are still required for the resulting cryptographic proof.
Common failures
Approvals fail after changing the request
Create a new approval for the exact request body.
Duplicate approver keys fail
n is the number of distinct approvers.
Approval nonce is rejected on second use
Approval nonces are one-time.