IDV attestation enclave
The problem this solves
In the default shyvoting-v1 identity flow, something has to hold the key that signs idv_attestation_sig — proof that Didit approved a given voter_pub_key for a given poll_id. If that key lives in the same backend the operator already controls, the operator can, in principle, forge an attestation for a registration Didit never approved. Oracle-forgery resistance (the property that "the IDV provider cannot forge a ballot because it never holds sk_v") only covers the voter's key — it says nothing about who holds the IDV's signing key.
An IDV attestation enclave closes that gap: a small, dedicated service that generates and keeps its own signing key, runs inside a confidential-computing instance (memory encrypted from the host), and independently re-verifies every session against Didit's real API before it will sign anything. Populist's populist-idv-enclave (OCI, AMD SEV-SNP) is the reference implementation; the pattern applies to any shyvoting-v1 deployment using Didit.
This page documents that pattern so other consumers don't have to rediscover its pitfalls — several of which only surfaced when Populist's own deployment moved from being reached by raw IP to being fronted by Cloudflare.
Architecture
device enclave (confidential VM) Didit
| session_id | |
| voter_pub_key | |
| poll_id | |
|----- POST /attest ----->| |
| |----- GET session/{id}/decision --->|
| |<---------- Approved / Rejected ----|
| | (only signs if Approved) |
|<--- idv_attestation_sig-| |
- The enclave generates an Ed25519 keypair once, on first boot, and never exports the private key.
GET /pubkeyreturns the public half — publish this, it's meant to be verifiable, not secret. POST /attesttakes{session_id, voter_pub_key, poll_id}, calls Didit's real session-decision API directly, and only signssha256(voter_pub_key || poll_id)if the decision is genuinelyApproved. The operator cannot produce a valid signature without a real Didit approval, because they don't hold the signing key and can't fake Didit's response to this service.- A local SQLite table rejects re-signing the same
session_idfor a different key/poll — defense-in-depth only. The authoritative replay check belongs on-chain (reject a registration tx whosedidit_session_idhas already been seen), the same pattern as the existingidentity_hashuniqueness check. An operator with instance access could reset the local table; they cannot rewrite chain history.
What this achieves vs. what it doesn't (yet)
Achieves: the operator cannot forge an attestation for a fabricated registration.
Does not yet achieve: full SEV-SNP attestation report verification — proving to an outside party that this exact, published code (and no other) is what produced a given signature. That requires the /dev/sev-guest report path plus a verifier against AMD's attestation chain, which isn't built yet. Today's guarantee rests on (a) SEV-SNP memory encryption protecting the process from the host, and (b) the Didit API key being readable only by this instance's own cloud identity, not by the operator generally. Be explicit about this distinction if you're describing the guarantee to anyone relying on it — "hardware-encrypted and independently-verifying" is not the same claim as "remotely attestable," and this deployment is the former today.
Deploying the enclave
Cloud identity, not static credentials
The enclave needs two things from the cloud provider: the Didit API key (from a secrets manager) and write access to an audit-log bucket. Neither should be a static credential on disk — use the provider's instance-identity mechanism (OCI instance principals, AWS instance profiles, GCP service account attached to the VM, etc.) scoped as narrowly as possible:
- Read-only on exactly the one secret holding the Didit API key.
PutObject/write-only on exactly the one bucket (and ideally one prefix within it), never delete.
A real gotcha we hit: OCI dynamic groups (the mechanism that says "this policy applies to this specific instance") have a matching-rule field that is not set automatically — if you provision the dynamic group and its policy but forget to set a real matching rule (e.g. instance.id = '<ocid>'), the group matches nothing, the policy silently never applies, and every call using instance-principal auth fails with an "or you are not authorized to access it" error that reads exactly like a missing/misnamed resource. Check matching-rule is non-null as an explicit step — it's not implied by the dynamic group merely existing.
Storage retention
Write audit/telemetry records to a bucket with a genuine WORM (write-once-read-many) retention rule — OCI Object Storage retention rules, AWS S3 Object Lock in Compliance mode, or equivalent. This matters more than it looks: an unlocked retention rule (or Cloudflare R2's "Bucket Locks," which explicitly can be removed by administrators) only protects against accidental deletion, not a privileged insider — locking the rule is what makes it genuinely immutable, and locking is irreversible for the rule's duration, so decide the retention period deliberately before locking.
Self-telemetry: log from inside the enclave, not from an external witness
It's tempting to add an external, unattested proxy (a Cloudflare Worker, a logging sidecar) to independently record "did this attestation request happen," as a check against the enclave lying. This doesn't actually add trust. An externally-authored witness is just more operator-controlled code with no attestation of its own — nothing stops the same operator from writing that witness to agree with a fabricated origin record, since they author both. It only catches sloppy, inconsistent tampering, not a deliberate operator willing to fabricate two records consistently.
The enclave's own code is the actually-trusted vantage point (hardware-encrypted, independently Didit-verifying). So it should log its own request history itself — every /attest call, success or failure, written by the enclave's own code to the same WORM-protected bucket:
async function logAttestRequest(record) {
const client = await getObjectStorageClient(); // instance-principal auth
await client.putObject({
namespaceName: AUDIT_LOG_NAMESPACE,
bucketName: AUDIT_LOG_BUCKET,
objectName: `attest-requests/${record.timestamp}-${crypto.randomUUID()}.json`,
putObjectBody: Buffer.from(JSON.stringify(record)),
contentType: "application/json",
});
}
Log the caller's real IP (see the Cloudflare note below), timestamp, session_id/poll_id, and outcome (signed / didit_not_approved / replay_conflict / bad_request / internal_error). Make the write non-fatal — a telemetry-write failure must never affect the actual attestation response the caller is waiting on.
Fronting the enclave with Cloudflare
There's a real reason to put the enclave behind Cloudflare rather than a raw IP: DDoS protection and hiding the origin IP for a service that's now a meaningful attack target. Two things break silently if you do this without adjusting for them, both found the hard way:
1. The real caller IP moves to a header
Once Cloudflare proxies the connection, req.ip on the enclave is Cloudflare's own edge IP, not the real client. Use CF-Connecting-IP instead, and keep the raw peer IP too for debugging:
const caller = {
ip: req.get("cf-connecting-ip") || req.ip,
directPeerIp: req.ip,
cfRay: req.get("cf-ray") || null, // correlate with Cloudflare's own analytics if ever needed
// ...
};
2. TLS certificate pinning breaks — in two separate ways
If your mobile client pins the enclave's TLS certificate directly (a reasonable thing to do for a self-signed cert reachable by raw IP), fronting it with Cloudflare breaks the pin two different ways at once, and both need fixing:
a. The cert itself changes. TLS now terminates at Cloudflare's edge with Cloudflare's certificate, not the enclave's own — pinning code must target what the client actually sees post-proxy, not the origin's cert.
b. Cloudflare's leaf rotates on its own schedule. Cloudflare's Universal SSL certs renew roughly every 90 days, entirely outside your control. Pin that leaf directly and the app will hard-fail closed on the next rotation unless someone proactively updates the pin. Pinning the intermediate CA certificate instead (e.g. Google Trust Services' WE1) survives leaf rotation, at the cost of trusting that CA's intermediate generally rather than one exact certificate — a reasonable trade for most deployments.
Both require the pinning code itself to support checking any certificate in the chain, not just chain[0], and to handle whatever key algorithm the CA actually uses. A naive pinning implementation often hardcodes both assumptions — checks only the leaf, and assumes RSA (reconstructing SubjectPublicKeyInfo DER by prepending a fixed RSA header to the raw key bytes SecKeyCopyExternalRepresentation-style APIs return). Cloudflare's actual certificates are typically EC P-256, not RSA — reconstructing SPKI with an RSA header against an EC key silently produces a hash that can never match anything, which is a much worse failure mode than a stale pin: it looks like pinning "works" until you check that the resulting hash is actually correct. Support both key types explicitly (or generalize to parse the real SPKI from the certificate DER rather than reconstructing it), and return no match — fail closed — for any key type/size you don't explicitly recognize, rather than guessing.
Get the pin for an intermediate certificate from the real chain, not a downloaded root bundle:
echo | openssl s_client -connect your-enclave.example.com:8443 \
-servername your-enclave.example.com -showcerts 2>/dev/null \
> chain.txt
# split chain.txt into individual certs, then for the one you want to pin:
openssl x509 -in intermediate.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | base64
Client integration (iOS)
let client = EnclaveAttestationClient(
baseURL: shyconfig.identity.attestationServiceBaseURL,
pinnedHost: URL(string: shyconfig.identity.attestationServiceBaseURL)?.host,
pinnedSPKISHA256Base64: shyconfig.identity.attestationServiceTLSPinSHA256Base64
)
let response = try await client.attest(
sessionId: diditSessionId,
voterPubKey: voterPubKeyHex,
pollId: pollId
)
// response.idvAttestationSigHex goes into the ballot-cast tx's idv_attestation_sig field
shyconfig.json fields:
{
"identity": {
"attestation_service_base_url": "https://attest.example.com:8443",
"attestation_service_tls_pin_sha256_base64": "<Base64(SHA-256(SPKI DER)) of the pinned cert>"
}
}
Known flagged discrepancy, not silently papered over: the reference enclave signs sha256("<voter_pub_key>:<poll_id>") — a colon-joined string. ShywareLLC/core's verifier (services/identity/didit.go) checks sha256(voter_pub_key || poll_id) — bare concatenation, no separator, matching the convention used everywhere else in that file. If you're standing up a new deployment, don't copy the colon-joined format without checking which side you're actually verifying against; the client does not attempt to reconcile this itself, since it never constructs the signed message.
Wire contract summary
| Endpoint | Auth | Purpose |
|---|---|---|
GET /health | none | Liveness + whether the Didit key loaded |
GET /pubkey | none (not secret) | The enclave's own public key — publish this |
POST /attest | none (self-authenticating: only a genuine Didit Approved produces a signature) | Independent Didit re-verification + signing |
POST /audit-log | shared secret (Authorization: Bearer <secret>) | Origin forwards Didit webhook records for durable storage |
/audit-log needs its own auth, unlike /attest: a forged /attest call simply can't produce a valid signature without a real Didit approval, so anyone can call it. /audit-log has no equivalent self-verifying property — the caller is just handing over a record the origin already validated via its own Didit HMAC check — so it needs a real shared secret, checked with a constant-time comparison.