Skip to content
SPEC-CRED-0003SpecificationIn forceversion 1.0.02 October 2026

This specification defines how the revocation status of a credential is published and verified; it is for issuing institutions and verifier developers.

When to read

In brief

The institution gives every credential a random position in a large bit list (iptal listesi (status list)Her belgenin tek bir konumu olduğu sıkıştırılmış, imzalı liste; geçerli, askıda ya da iptal olduğunu söyler.). When the credential is revoked, the value of that bit changes; the list is signed and published at fixed intervals. The verifier downloads the list in advance and, when the credential is shown, checks its own copy without asking anyone; so the institution cannot learn where and when the credential was shown. The fingerprint of every publication is written to the anchor log; if the list is rolled back, the verifier notices.

Today the anchor is the anchor log of the güven listesi (trust list)Bir ülkenin kök sertifikalarını, belge verenlerini ve kayıtlı relying party'lerini taşıyan imzalı liste. Bugün Tamga'da güven bu listelere dayanır; ortak defter sonra gelir. publisher (anchors.jsonl). The contract interface in §4 belongs to the chain stage (ADR-0009).


Scope ​

This specification defines how, in Tamga, the revocation status of a belge (credential)Belge verenin imzaladığı ve kişinin cüzdanında duran dijital belge; kişi yalnız istenen alanları gösterir. (iptal (revocation)Bir belgenin süresi dolmadan geçersiz kılınması; belge veren bunu doğrulayıcıların denetlediği iptal listesinde işaretler.) is published, anchored and verified.

The decision and its rationale are in ADR-0008 and are not repeated here. In short: the bitstring list is hosted off-chain; only the URI + content hash + version anchor sits on the chain.

Standards basis: IETF Token Status List (draft-ietf-oauth-status-list-20, 2026-04-20; the same as EU CIR 2026/1731). The draft is in the RFC Editor queue.

Out of scope: cryptographic accumulators / ZK (Zero-Knowledge proof)Bir bilginin doğru olduğunu, bilginin kendisini göstermeden kanıtlayan ispat; örneğin doğum tarihi olmadan "18 yaşından büyük". revocation (RS-REVOCATION-0001, expansion stage).


1. Model and roles ​

┌─────────────┐  issuance  ┌──────────┐  present  ┌──────────┐
│   Issuer    │──────────▶ │  Holder  │─────────▶ │ Verifier │
│ (university)│            │ (wallet) │           │(employer)│
└──────┬──────┘            └──────────┘           └────┬─────┘
       │ publishes                                     │ fetches
       ▼                                               ▼
┌──────────────────────────┐              ┌────────────────────┐
│  Status List Token       │◀─────────────│  cache /           │
│  status.<issuer-domain>  │              │  prefetch (§9)     │
└──────────┬───────────────┘              └────────────────────┘
           │ hash + version
           ▼
┌──────────────────────────┐
│  StatusListRegistry      │  ← chain (anchor only)
└──────────────────────────┘
RoleTask
Status ProviderProduces, signs and publishes the Status List Token. By default the issuer itself.
Referenced TokenThe credential whose status is tracked — in Tamga an SD-JWT VC.
AnchorThe record in the StatusListRegistry contract.

Terminology note: the standard says "Relying PartyCüzdandan belge isteyen ve doğrulayan kayıtlı kuruluş: işveren, web sitesi, kurum."; in Tamga this is the VerifierGösterilen belgeyi denetleyen taraf: imza, belge verenin güven listesindeki kaydı, durum ve politika. Relying party diye de anılır. (the same concept as SPEC-BC-0001 RelyingPartyRegistry).


2. Credential side (referenced token) ​

Every Tamga credential that uses a status list carries a status claim (SPEC-SCHEMA-0001 §4.2 — mandatory when tamga.uses_status_list = true):

json
"status": {
  "status_list": {
    "idx": 48213,
    "uri": "https://status.bilgi.edu.tr/v1/sl/7f3a9c21"
  }
}
FieldMeaning
idxThis credential's index in the list
uriAddress of the Status List Token

The status claim is sd: "never" — it cannot be hidden with selective disclosureBir belgeden yalnız doğrulayıcının istediği alanların gösterilmesi, fazlasının değil. and is visible in every presentation. The privacy consequences are covered in §6 and §10.


3. Status List Token ​

3.1 Structure ​

JWS compact serialization. Header:

json
{
  "alg": "ES256",
  "kid": "sl-2026-a",
  "typ": "statuslist+jwt",
  "x5c": ["MIIB...", "MIIC..."]
}

Body:

json
{
  "iss": "https://issuer.bilgi.edu.tr",
  "sub": "https://status.bilgi.edu.tr/v1/sl/7f3a9c21",
  "iat": 1789000000,
  "exp": 1789180000,
  "ttl": 3600,
  "status_list": {
    "bits": 2,
    "lst": "eNrbuRgAAhcBXQ..."
  }
}
ClaimRequirementTamga profile
issMandatoryIssuer identifier; in the same trust chain as the Referenced Token's iss
subMandatoryList URI — exactly the same as uri in the Referenced Token
iatMandatoryPublication time
expMandatory in Tamgaiat + 50 hours (§8.2)
ttlMandatory in Tamga3600 (seconds) — §8.1
status_list.bitsMandatoryAlways 2 in Tamga — §3.3
status_list.lstMandatoryCompressed byte array, base64url
status_list.aggregation_uriOptionalRecommended in Tamga — §9.2

3.2 Serving ​

GET /v1/sl/7f3a9c21 HTTP/1.1
Host: status.bilgi.edu.tr
Accept: application/statuslist+jwt

HTTP/1.1 200 OK
Content-Type: application/statuslist+jwt
Content-Encoding: gzip

eyJhbGciOiJFUzI1NiIsImtpZCI6InNsLTIwMjYtYSIsInR5cCI6InN0YXR1c2xpc3Qr...
  • The response body is the raw JWS compact serialization — it is not wrapped in a JSON envelope.
  • Content-Encoding: gzip should be used (lst is already compressed, but the base64url form of the JWT benefits from gzip).
  • The CWT form (application/statuslist+cwt) is not used in Tamga’s initial stage; it will be considered in the state stage together with the mdoc profile (ADR-0006).

3.3 The bits = 2 decision ​

The standard allows the values 1, 2, 4 and 8 for bits. Tamga always uses 2.

OptionMeaningTamga assessment
bits = 1Only valid/invalidSuspension cannot be represented
bits = 24 statesChosen
bits = 4 / 816 / 256 statesSize grows 2–4 times, for no benefit

Status values and their Tamga meaning:

ValueStandard nameTamga meaning
0x00VALIDValid
0x01INVALIDPermanent revocation — irreversible
0x02SUSPENDEDTemporary suspension — reversible
0x03(reserved)Not used in Tamga

Why suspension is needed: when a plagiarism investigation is opened about a diploma, the institution does not want to revoke it permanently — it wants to suspend it until the investigation ends. With bits = 1 the institution would either revoke it permanently without cause or do nothing. Both are wrong.

Size cost: 100,000 indices × 2 bits = 25 KB raw; compressed, typically a few hundred bytes (the list is mostly zeros). Negligible.

3.4 Signing key ​

The Status List Token is signed with a key separate from the credential signing key, but bound to the same X.509 chain (SPEC-ID-0002).

Rationale:

  1. Different frequency of use. The credential key is used rarely and sits in an HSM. The status key signs once an hour; it has to sit in an online system. Making them the same would mean exposing the HSM key to a permanently online service.
  2. Damage isolation. If the status key is compromised, the attacker can publish a fake status but cannot produce a fake diploma.
  3. Rotation. The status key can be rotated yearly; rotating the credential key is a much heavier operation because it affects past credentials.

kid is always filled in and changes on rotation.


4. Anchor ​

4.1 What contentHash hashes ​

contentHash = SHA-256( ASCII bytes of the JWS compact serialization string )

That is, the string after Content-Encoding: gzip is decoded. The full token, including the dot separators.

Invariant: a token with the same version number is always the same bytes. The Status Provider cannot produce different bytes for the same version.

4.2 Contract interface ​

Under ADR-0008 decision 4, StatusListRegistry.sol has been rewritten. The earlier design that held a bitmap is void.

solidity
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.24;

interface IStatusListRegistry {
    enum ListStatus { NONE, ACTIVE, RETIRED }

    struct ListAnchor {
        bytes32    issuerId;
        string     listURI;       // the sub claim of the Status List Token
        bytes32    contentHash;   // §4.1
        uint32     listSize;      // toplam indeks kapasitesi
        uint8      bitsPerEntry;  // Tamga'da her zaman 2
        uint64     version;       // monoton artan
        uint64     publishedAt;   // time of the latest publication
        ListStatus status;
    }

    /// @notice Tamga profile: the list must have at least this index capacity.
    function MIN_LIST_SIZE() external pure returns (uint32); // 100_000

    event ListRegistered(bytes32 indexed listId, bytes32 indexed issuerId, string listURI, uint32 listSize);
    event ListPublished(bytes32 indexed listId, uint64 version, bytes32 contentHash, uint64 publishedAt);
    event ListRetired(bytes32 indexed listId, string reason);

    error ListExists(bytes32 listId);
    error UnknownList(bytes32 listId);
    error NotListOwner(bytes32 listId, address caller);
    error VersionNotMonotonic(uint64 current, uint64 submitted);
    error PublishedAtNotMonotonic(uint64 current, uint64 submitted);
    error ListSizeTooSmall(uint32 submitted, uint32 minimum);
    error InvalidBitsPerEntry(uint8 submitted);
    error ListNotActive(bytes32 listId);

    /// @notice listId = keccak256(abi.encodePacked(issuerId, bytes(listURI)))
    function listIdOf(bytes32 issuerId, string calldata listURI) external pure returns (bytes32);

    /// @notice Listeyi bir kez kaydeder. onlyIssuer(issuerId).
    function registerList(
        bytes32 issuerId,
        string calldata listURI,
        uint32 listSize,
        uint8 bitsPerEntry
    ) external returns (bytes32 listId);

    /// @notice Called in every publication cycle (§5). version must increase monotonically.
    function publishList(
        bytes32 listId,
        bytes32 contentHash,
        uint64 version,
        uint64 publishedAt
    ) external;

    function retireList(bytes32 listId, string calldata reason) external;

    function getListAnchor(bytes32 listId) external view returns (ListAnchor memory);

    /// @notice Used in verification: does the hash match and is the version fresh enough?
    function matchesContentHash(bytes32 listId, bytes32 hash) external view returns (bool);
}

4.3 Removed interfaces ​

The following have been removed and must not be added back:

RemovedWhereWhy
setRevoked(issuerId, index)StatusListRegistry.solThe chain no longer holds bits
setRevokedBatch(...)samesame
unsetRevoked(...)samesame
getChunk(issuerId, chunkIndex)samesame
_setBit(...)samesame
IStatusList.isRevoked(issuerId, index)ITrustQueries.solThe chain cannot answer this question

The last row is critical. Leaving an interface that asks a question it cannot answer makes the caller read a false return as "not revoked". The interface must go so that compilation fails.

Knock-on effect: CredentialGate.sol cannot read revocation status from the chain. Under ADR-0008 consequences §3, on-chain credential gating has to rely on fresh proof supplied by the caller → SPEC-AGENT-0001.


5. Publication cycle ​

5.1 Fixed interval and noise ​

The Status Provider republishes the list at fixed intervals. Default interval: 1 hour.

It is published even if nothing changed. This is not optional.

Rationale (ADR-0008 §1): if the list were published only when a revocation happens, the mere existence of a publishList transaction on the chain would leak "a revocation happened this hour" to every validator in the network. Combined with the block timestamp — the date of a disciplinary decision, the day someone left a job — the credential holder could be narrowed down.

In a fixed cycle the only thing visible from outside is "there is a new version of the list". Which bit changed, or even whether a bit changed at all, is not visible.

5.2 Publication algorithm ​

Every interval T (default 3600 s):

  1. Take pending status changes from the queue (if any; may be empty).
  2. Update the bitstring.
  3. Compress → lst.
  4. Build the token body:
       iat = now, exp = now + 50h, ttl = 3600, version = previous + 1
  5. Sign with the status key → JWS compact serialization.
  6. contentHash = SHA-256(ASCII bytes of the string)
  7. Write to the CDN / status server.          ← FIRST
  8. publishList(listId, contentHash, version, iat)   ← THEN

The order of steps 7–8 is binding. In the reverse order a version would exist that is registered on the chain but not reachable, and every verification would get stuck at §7 step 6.

5.3 Interval versus freshness ​

In the worst case a revocation becomes visible with a delay of one publication interval. At the default of 1 hour this is acceptable.

Schemas that need a shorter interval can define one in the tamga block; but the shorter the interval, the more often the chain is written. Going below 10 minutes is not recommended.

Emergencies: publishing outside the interval is not done — it destroys the privacy benefit of §5.1. If an urgent revocation is needed, the right tool is not the status list but suspending the belge veren (issuer)Belgeyi imzalayıp veren kurum: üniversite, meslek kuruluşu, kamu kurumu ya da şirket. certificate (SPEC-ID-0002) or setting the schema to REVOKED (SPEC-SCHEMA-0001 §9.2).


6. Index allocation — privacy critical ​

This section is the part of the specification most easily implemented wrongly.

6.1 Indices are allocated at RANDOM ​

The idx value is chosen cryptographically at random within the list's capacity. A sequential counter is not used.

What sequential allocation leaks: the idx claim is sd: never — visible in every presentation. With sequential allocation a graduate with idx = 12 is the 12th person to receive a credential on that list. This:

  • Reveals the graduation/enrolment order
  • The idx difference between two graduates approximates the time between them
  • In small departments it almost singles the person out
  • It approximates the date even if the graduate did not disclose awarding_date

So information hidden with selective disclosure leaks through the index.

6.2 Fill ratio ​

Random allocation alone is not enough. If the list is mostly empty, the distribution of the filled indices still carries information.

RuleValue
Minimum list capacity100,000 indices
Maximum fill80% — beyond that a new list is opened
Minimum initial noiseWhen the list is created, 1% of the capacity is marked as "allocated" at random indices; the bit value stays 0x00 (valid) and they are bound to no credential — indistinguishable from real entries from outside

The last row prevents a single graduate from being the only filled index during the first days of a new list.

6.3 The list URI must be OPAQUE ​

This rule closes a leak caught while this specification was being written.

The list URI travels in the status claim, which is sd: never — so it is seen by the verifier in every presentation. The URI itself is therefore a claim.

Forbidden patterns:

URIWhat it leaks
.../sl/edu-2026-aGraduation year — even if awarding_date is hidden
.../sl/muhendislikFaculty/department — even if programme_title is hidden
.../sl/lisans-2026-guzCohort — both at once
.../sl/tip-fakultesi-2024Both + professional information

Rule: the list identifier must be opaque — a meaningless, randomly generated string:

https://status.bilgi.edu.tr/v1/sl/7f3a9c21

The Status Provider keeps the mapping between this identifier and the cohort internally; it never leaves.

6.4 How lists are split ​

When the capacity is full, a new list is opened. Splitting criteria:

CriterionAssessment
By year / termForbidden — the same leak as §6.3; even with an opaque URI, membership of the list reveals the year
By department / facultyForbidden — same reason
By schema (credential type)Unavoidable and harmless — vct is in the clear anyway
Next one when capacity is fullRecommended

So the only dividing axis is the credential type; apart from that, a new list is opened only because the old one is full, and the credentials in it share no common property other than their type.

Implementation note: this forbids the natural-looking and operationally easy design "2026 graduates are on list A". Administrative convenience is not worth a privacy leak.


7. Verification algorithm (verifier) ​

Normative. Run for every credential that carries a status claim.

Ş1.  Does the credential have status.status_list?
     If the schema says tamga.uses_status_list = true and the claim is missing → REJECT.

Ş2.  Take idx and uri. If the uri scheme is not https → REJECT.

Ş3.  Obtain the Status List Token:
     a) Is it in the prefetch cache and fresh (§8)? → use it.
     b) Otherwise GET <uri>, Accept: application/statuslist+jwt
     c) If neither works → "COULD NOT VERIFY" (not REJECT, §7.1)

Ş4.  Verify the token:
     - typ == "statuslist+jwt"
     - signature valid, the x5c chain links to the issuer's X.509 chain
       (SPEC-ID-0002); root anchored in RootCARegistry
     - sub == the uri in the credential  → otherwise REJECT
     - iss in the same trust chain as the credential's iss → otherwise REJECT

Ş5.  Freshness:
     - exp has passed → "COULD NOT VERIFY"
     - iat + ttl < now → the token is stale; refresh or "COULD NOT VERIFY"
       depending on policy
     (the exp/ttl claims decide, not the HTTP cache headers)

Ş6.  CHAIN ANCHOR:
     listId = keccak256(issuerId, sub)
     anchor = StatusListRegistry.getListAnchor(listId)
     - anchor.status == ACTIVE  → otherwise REJECT
     - SHA-256(token bytes) == anchor.contentHash → otherwise REJECT
     - version in the token lower than anchor.version → REJECT (rollback)

Ş7.  Capacity: idx < anchor.listSize → otherwise REJECT

Ş8.  Decompress lst and read the value at position idx with bits=2.

Ş9.  Interpret the value:
     0x00 → VALID
     0x01 → REVOKED      → REJECT
     0x02 → SUSPENDED    → REJECT (shown to the user as "suspended")
     0x03 → unrecognised → REJECT

Ş10. Write the result to the audit log together with the version and iat of the token used.

7.1 "Invalid" versus "could not verify" ​

In Ş3(c) and Ş5 the result is "could not verify", not "invalid", and the verifier must show the two differently to the user.

The difference between "this diploma has been revoked" and "I cannot check the revocation status right now" is the difference between someone being hired or not. The same distinction applies to schema resolution in SPEC-SCHEMA-0001 §7.

7.2 Why Ş6 cannot be skipped ​

Without the chain anchor the issuer could silently roll back the list on its own server — showing a credential it revoked as "valid" again. version monotonicity and the contentHash match prevent this.


8. Caching and freshness ​

8.1 ttl and exp ​

ClaimTamga valueMeaning
ttl3600The verifier may use it for this long without refetching
expiat + 50 hoursAfter this moment the token must not be used at all

Why exp is much longer than ttl: ttl is the "freshness target", exp the "absolute limit". If the status server is down for a few hours, verification must be able to continue with the token at hand. 50 hours covers a weekend outage.

The claims decide. The standard requires the verifying party to give precedence to the token's exp and ttl claims over HTTP cache headers. The CDN's Cache-Control header cannot override this.

8.2 Per-schema freshness thresholds ​

A verifier may be stricter depending on the risk level:

RiskMaximum accepted token age
Lowttl (1 hour) is enough
Medium6 hours
High (official transaction)1 hour, and version exactly matching the chain anchor

8.3 Offline verification ​

If the chain anchor (Ş6) cannot be read, verification cannot be completed. In offline mode the verifier:

  • May continue with the cached token and the last known anchor,
  • Must show the user the time of the last synchronisation,
  • Marks the result as "verified offline".

9. Privacy ​

9.1 Fetching per verification is forbidden ​

This is the most important privacy rule.

If the verifier does GET <uri> at every verification, the Status Provider learns:

"Right now someone is verifying a credential from my list X."

The source IP gives the verifier away. A university could learn this way which employers its graduates apply to — a new tracking channel that paper diplomas do not have.

Rule: verifiers obtain Status List Tokens through scheduled bulk prefetching (recommended: within the ttl interval), not at verification time. Verification is done from the cache.

The @tamga-network/verifier SDK makes this behaviour the default; fetching per verification must be possible only when explicitly enabled (ARCH-0005).

9.2 List aggregation ​

The standard defines an optional aggregation mechanism that lets the issuer publish its list URIs collectively (aggregation_uri).

Recommended in Tamga: the verifier can discover and bulk-download all of an issuer's lists from one address. This makes §9.1 practical.

9.3 Herd privacy ​

The fewer indices a list has, the more distinguishing idx is. That is the reason for the minimum capacity of 100,000 and the 1% initial noise in §6.2.

The small-institution problem: a vocational school with 300 graduates means 300 filled indices in a list of 100,000. The list is large but the herd is small. In that case the herd is bounded by the institution, not by the list, and there is no technical fix — the iss claim names the institution openly anyway.

Recorded as an accepted limitation.

9.4 Unsolved: idx correlation ​

The idx + uri pair is fixed and sd: never. A graduate who presents the same diploma to two different verifiers can be matched if those two verifiers collude.

This is a known, structural limit of the Token Status List. The fix is batch issuance: a separate credential copy with a different idx for each presentation (SPEC-CRED-0001 §5, SPEC-SCHEMA-0002 §2.1.3).

This is an accepted risk in the initial stage and must be disclosed openly to pilot participants → PM-GTM-0001.


10. Operations ​

10.1 Component ​

PropertyValue
Addressstatus.<issuer-domain>
ContentStatic file (signed token), behind a CDN
WritingPublication job (cron), once an hour
KeyStatus signing key, online, separate from the credential key (§3.4)
Availability target99.5% — not on the critical path thanks to the exp=50h buffer

It is a separate component for every issuer and is part of the issuer onboarding checklist (ARCH-0004).

10.2 Small institutions and centralisation risk ​

Running a status server can be heavy for small institutions. Tamga can offer hosting — but then Tamga sees every revocation in the network.

This is a real centralisation point and is governed by policy in PM-GOV-0001 decision P1: the signing key stays with the institution (the foundation cannot publish a fake status), no access log is kept, the list of hosted issuers is public, and if they exceed 30% of active issuers the matter goes to the council agenda.

10.3 Disaster scenarios ​

ScenarioEffectRecovery
Status server outageContinues from cache for up to 50 hoursThe server comes back
Loss of the status keyNo new publication possibleNew key + kid rotation; the same list continues
Compromise of the status keyFake status can be publishedCertificate revocation → every token fails at Ş4 → republication with a new key
Loss of the list fileVerification stopsThe bitstring is regenerated from the issuer database; the contentHash on the chain is kept to verify past versions

The last row requires the issuer to keep its revocation queue permanently. The list is a derived product; the source is the database.


11. Invariants ​

#Invariant
S1There are no revocation bits on the chain; only the anchor.
S2bits is always 2.
S3version increases monotonically; a decreasing version is rejected.
S4A publication is registered on the chain after it has been written to the CDN (§5.2).
S5Published at a fixed interval even if nothing changed (§5.1).
S6No publication outside the interval ("emergency").
S7idx is allocated at random; no sequential counter (§6.1).
S8The list URI is opaque; it encodes no year, department or cohort (§6.3).
S9Lists are split by no criterion other than type (§6.4).
S10List capacity ≥ 100,000; fill ≤ 80%.
S11The status key is separate from the credential signing key (§3.4). The certificate fingerprint of the status key is published in the trust list in the institution entry's delegate_keys[] field with purpose: "status_list"; at D3 the verifier matches the token signer against this entry (no entry → INDETERMINATE, mismatch → REJECTED). An idx outside the list is not read as valid (D6 REJECT).
S12The verifier does not fetch per verification; it uses bulk prefetching (§9.1).
S13The exp/ttl claims override HTTP cache headers.
S14"Invalid" and "could not verify" are shown to the user differently (§7.1).

Open topics ​

  1. Chain load of writing to the chain once an hour with ttl = 3600 — CLOSED (2026-09-09, ARCH-0004 §7). 1,000 issuers = 24,000 transactions a day = 0.28 TPS; with 2 s blocks, 0.55 transactions per block. Negligible for QBFT. Persistent state does not grow either: publishList overwrites the existing slots. What actually grows is the archive history, ~1.8 GB a year.
  2. How is a return from suspension (0x02) to valid represented in the verifier's audit log? Is a retrospective "was it suspended at that moment" query needed?
  3. The 1% initial noise of §6.2 consumes capacity because it counts fake indices as "allocated". Is there a more elegant solution?
  4. Should Status List Aggregation be mandatory? Today it is "recommended"; if the verifier side takes prefetching seriously, making it mandatory may make sense.
  5. Are multiple Status Providers (several lists of one issuer on different servers) supported? Implicitly yes today; should it be written explicitly?

Related documents ​

ADR-0008 · SPEC-CRED-0001 · SPEC-CRED-0002 · SPEC-SCHEMA-0001 · SPEC-SCHEMA-0002 · SPEC-BC-0001 · SPEC-ID-0002 · PM-TRUST-0001 · ARCH-0004 · ARCH-0005 · PM-GOV-0001 · PM-GTM-0001


Status ​

In force — version 1.0.0 (2026-10-02).