Skip to main content

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 is intended to close 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,-| |
| identity_hash | |
  • The enclave generates an Ed25519 keypair once, on first boot, and never exports the private key. GET /pubkey returns the public half — publish this, it's meant to be verifiable, not secret.
  • POST /attest takes {session_id, voter_pub_key, poll_id}, calls Didit's real session-decision API directly, and — only if the decision is genuinely Approved — derives identity_hash = sha256(document_key || poll_id) from the same decision payload's id_verification fields (document_type, issuing country, document_number) and signs sha256(voter_pub_key || poll_id || identity_hash). 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.
    • Why id_verification's document fields, not a "person_id": Didit's real decision API has no stable per-person identifier field at all — confirmed directly against a live session. The document data (a real government ID, re-verified independently each session) is the closest signal Didit's API actually provides that's stable across devices for the same real person. This is also the concrete reason this service needs to run inside a confidential-computing instance at all: it's handling real document PII in memory, not just relaying a boolean. Raw document data never leaves the process — only the resulting one-way hash does.
  • A local SQLite table binds (session_id, poll_id) to the attested identity/key — defense-in-depth only. The authoritative replay check belongs on-chain (s.consumedSessions, scoped by (session_id, poll_id), binding one identity per poll; the same approved session may participate in different polls), the same pattern as the existing identity_hash per-poll uniqueness 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.

Attestation status: challenge-bound report capture and AMD certificate-chain verification are implemented and deployed. The live Oracle enrollment service binds a fresh verifier challenge, its signing key and source digest into REPORT_DATA, and signs the returned evidence. Its report still fails signature verification against the matching AMD VCEK; Oracle support SR 4-0004016476 is active. Consequently, this deployment does not yet establish independently verified AMD hardware authenticity or an accepted workload measurement. A self-reported source digest does not prove that Node executed that source. The verified GCP ZK setup ceremony attests a separate setup workload and does not attest the Oracle enrollment service. Do not label this enrollment service remotely verified until both endorsement verification and accepted workload evidence pass.


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

Reference implementation​

The actual, complete source this page describes is public: https://github.com/ShywareLLC/idv-enclave. Nothing in it is Populist-specific or secret -- every credential is read from the environment or fetched via instance-principal auth at boot, never hardcoded. Start there rather than re-implementing from this page's description alone.

It also includes a durable Apple App Attest device-key store (POST /register-device, GET/DELETE /device-key/{keyId}), added after the base /attest//sign-poll-create//audit-log set this page otherwise describes. It's storage only, deliberately: App Attest's real cryptographic verification (CBOR parsing, cert-chain verification against Apple's root CA) already exists and is tested in ShywareLLC/core's services/attest.AppAttestVerifier -- these endpoints exist so that verifier's key store survives a relay process restarting, via services/attest.EnclaveKeyStore, rather than to duplicate the verification logic in a second language. Kept in its own OCI Object Storage bucket, separate from the audit log's, because device records must be deletable on de-registration and the audit log's retention rule is deliberately WORM.

Operator access continuity: defense-in-depth, not a structural guarantee​

Be precise about which category this falls in, because it is a different one from the rest of this system's claims. The two-list invariant holds by exclusion: the join is never written, which is true regardless of any party's behavior. Operator access continuity to a given enclave instance is not that kind of property, and should never be described as though it were. It is an ordinary operational-security concern -- the same kind any server's SSH key management is -- and it is addressed here by defense-in-depth (publishing the source, keeping the deploy path scripted and reproducible, treating any specific instance as replaceable), not by anything structurally excluded from the state space. That defense-in-depth is current best practice for this problem, and is worth doing well, but it should be named for what it is: a conventional security posture that depends on actually following the runbook below, not an information-theoretic guarantee that holds no matter what anyone does.

Within that category, the design does still reduce to a fairly clean question: the enclave holds no secret that anything else depends on keeping recoverable. Its signing key is generated fresh, in place, on first boot, so losing SSH access to a given instance changes nothing about what it can already prove -- it keeps signing correctly forever without it, because SSH was only ever a path to update code, not a precondition for the running service's correctness. Suspected compromise has exactly one recommended response, for an ordinary and unmysterious reason: stop trusting that instance's pubkey, rather than attempting to re-secure or rotate access to it in place. If an adversary had root inside the guest OS, they could already have read the private key straight off disk regardless of what happens to the SSH credential afterward -- SEV-SNP is a guarantee against the host, not against an adversary who already has a shell inside the guest.

Rotation / replacement procedure, assuming the source is public and a scripted deploy exists (see your own deploy tooling for the actual provisioning step -- this is the sequence, not a specific script):

  1. Launch a fresh confidential-computing instance from the public source. Don't reuse any state from the old one.
  2. Let it generate its own keypair(s) on first boot, same as any fresh instance would -- this is not a step you do to it, it's what happens automatically.
  3. Fetch its new /pubkey (and, if this deployment also signs operator-authorized transactions, /poll-pubkey).
  4. Update every consumer's pinned config to the new value(s) -- --didit-pubkey / --operator-pubkey-pem-file on validators, and any client-side TLS pin (attestation_service_base_url / attestation_service_tls_pin_sha256_base64 in shyconfig.json).
  5. Point DNS/ingress at the new instance.
  6. Terminate the old one. Its local replay-tracking table and audit history do not need to carry over -- the on-chain session-uniqueness check is the authoritative enforcement (see this page's own note on that table being defense-in-depth only); the audit log's durable copy already lives in object storage, independent of the instance itself.

If step 4 (updating every consumer's pin) is manual and error-prone in your deployment, that's the thing to fix before you need this procedure under pressure, not after.

Two gotchas found wiring this into a real deployment​

The Attester capability existing in a library doesn't mean any given binary uses it. api/server (ShywareLLC/core) has a WithAttester() hook and a real services/attest package (App Attest cert-chain verification, Play Integrity), but a production relay binary can simply never call WithAttester() at all -- there's no default-on behavior to accidentally get right. Grep the actual main.go you're deploying for WithAttester before assuming device attestation is enforced; a code comment claiming "X already satisfies this interface" is not the same as something actually being wired, and can go stale. In one real instance this meant every ballot submission's X-Attest-Token check was structurally unreachable, under every flag combination, until someone actually constructed a verifier and called WithAttester().

Registration and verification must read/write the same store, and there is exactly one legitimate store per device key. It's easy to end up with two independent app-attestation implementations that never talk to each other: e.g. a Node origin server with its own quick registration endpoint (own DB table, own -- possibly incomplete -- verification) and a separate Go relay with the real services/attest.AppAttestVerifier (real Apple-root cert-chain verification) but no registration endpoint of its own and an empty, unpopulated key store. Both can be "working" in isolation and still add up to every real client request failing verification (key never found) or, worse, to only the weaker of the two paths ever actually gating anything. Before wiring device attestation into a new binary, confirm registration and verification share one persistent store (services/attest.AppAttestKeyStore needs a real backing implementation -- MemKeyStore is test-only and does not persist across restarts or processes) and that the client only ever registers against that store.


"Attestation" means at least two unrelated things -- don't let a service built for one quietly get assumed to cover the other​

This page's enclave does IDV (Didit) re-verification and operator-authorized poll signing. It has nothing to do with, and was never built for, device/platform attestation (Apple App Attest, Google Play Integrity) -- a completely different concept that happens to share the word "attestation": Didit attestation says this is a real, biometrically-verified person; device attestation says this request comes from a genuine, unmodified app on a genuine device. A consumer's own validator/relay code may separately need device attestation (see services/attest in ShywareLLC/core) -- that verifier, its key store, and this enclave are three independent things. Don't probe an IDV-attestation enclave for a device-registration endpoint expecting to find one "hidden" under a different name; if it was built the way this reference deployment was, it genuinely isn't there, and every route on it (/attest, /sign-poll-create, /audit-log) will trace back to the Didit relationship or operator authorization, never to a device's own key. Grep your own comments for "Claim N" citations attached to either kind of attestation check before trusting them, too -- the two are easy to cross-cite by accident (found a real instance of exactly this: a device-attestation code comment citing the IDV re-attestation claim numbers).

If you do need durable per-device state near a confidential-computing enclave (e.g. a device attestation key store, keyed by device ID), consider extending the same object storage bucket and instance-principal identity the enclave already has for audit logging, rather than standing up a second store: a device record is the same shape as an audit entry (write-once at registration, read back occasionally by key), just with a different retention policy -- keep it in a separate bucket or prefix from the audit log itself, since the audit log's WORM retention rule shouldn't apply to something you need to be able to delete (e.g. on device de-registration).

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>"
}
}

Message format (resolved — both sides match): the reference enclave signs sha256(voter_pub_key || poll_id || identity_hash) — bare concatenation, no separator, matching ShywareLLC/core's verifier (services/identity/didit.go's diditDeviceAttestMessage) exactly, and the same no-separator convention used everywhere else in that file. identity_hash must be included on the resulting transaction's identity_hash field (BallotCastData/BallotUpdateData) — the chain verifies the signature covers it but has no way to recompute it itself, since it never sees the document data behind it.


Wire contract summary​

EndpointAuthPurpose
GET /healthnoneLiveness + whether the Didit key loaded
GET /pubkeynone (not secret)The enclave's own public key — publish this
POST /attestnone (self-authenticating: only a genuine Didit Approved produces a signature)Independent Didit re-verification + signing
POST /sign-poll-createshared secret (Authorization: Bearer <secret>)Signs an operator-authorized TxTypePollCreate transaction (see --operator-pubkey-pem-file on the validator) so poll creation can't be forged from a compromised origin alone -- the attacker would also need this secret AND to reach the enclave. Not self-authenticating like /attest: there's no independent third party (like Didit) to re-check a poll-creation request against, so a shared secret is the actual gate. Found undocumented in an earlier pass of this page -- if you're standing up a new deployment, don't assume /attest is the enclave's only signing capability without checking.
POST /audit-logshared 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.