Skip to main content

The DID Refresh in ArcBlock's Upcoming Chain Upgrade

Robert Mao
IdentityAgent IdentityVerifiabilityBlockchainSecurity

Our announcement of the upcoming ArcBlock chain and bridge upgrade outlined the transition. Here I want to look more closely at the DID work: how identities remain usable when services move, keys change, credentials expire, and agents pass work to other agents. The announcement covers the overall upgrade; this Engineering Blog covers the implementation and what applications need to supply when integrating it.

A request arrives with a principalDid. What can the verifier establish? Checking the identifier's format says little about whether the caller holds its key, who authorized an agent, or whether a credential is still valid. After a service moves or a key rotates, there are two more questions: where to connect, and which public key to trust.

This is a substantial refresh of our DID engineering. It brings identifier and service resolution, key continuity, authorization provenance, and credential status into verification steps that applications can combine. Some extend the original did:abt: design. Others draw on W3C DID Documents, KERI pre-rotation, SD-JWT, and bitstring status lists.

We introduced DID into ArcBlock's architecture in 2019, then continued using it in wallets, DID Connect, and application login. We have kept investing in that foundation; this round advances key rotation, credential privacy, and agent delegation together. ARC integration has followed a sequence: first get the system running reliably with simplified identities, then tighten verification. This work adds the evidence behind those identifiers. The account below is based on merged SDK and chain protocol source. It explains the data, the checks, and the inputs applications must provide. The examples use current interfaces and encodings; integrators supply the keys, trusted state, and request parameters. Check the applicable SDK release and whether a production entry point is enabled before integrating.

Resolution: keep the identity when the service moves

A service can move to another machine while people continue to find it through the same domain. Applications can also discover signature verification, encrypted communication, and file service endpoints through one identity. Separating whom to recognize from where to connect lets the service location change while preserving the identity relationship.

The 26-byte binary layout of did:abt: defined by the ABT DID Protocol remains intact. Its type fields describe the role, key type, and hash type, followed by the public key hash and checksum. Multi-method support makes the abt, afs, aos, and spaces representations explicit; did:name handles readable aliases. The resolver distinguishes cryptographic identifiers from name aliases, resolving a name before proceeding to public key verification.

Names have input constraints too. normalizeName applies Unicode NFKC and lowercase normalization, rejecting control characters, zero-width characters, leading or trailing whitespace, and certain mixed-script and confusable forms listed by the implementation. This removes some ambiguity before registration and resolution. Client presentation and naming policies still have a part in preventing phishing.

DNS introduces a specific encoding conflict: Base58 addresses are case-sensitive, while DNS label comparison is case-insensitive. RFC 4343 describes those DNS rules. The new toDnsDidLabel encodes the original DID bytes as a lowercase label; fromDnsDidLabel decodes it, validating the embedded checksum for a 26-byte ArcBlock DID and reconstructing the checksum address representation for an Ethereum address.

Original identifierDefault DNS labelLength
26-byte ArcBlock DIDk + fixed-width base3642 characters
20-byte Ethereum addresse + fixed-width base3632 characters

This preserves our three-layer addressing relationship:

text
custom.example
  CNAME → <dns-safe-label>.did.abtnet.io
           → current runtime endpoint

These interfaces convert between a DID and its gateway hostname. Here, did is a validated identifier. The return values are names; the calls do not create DNS records.

ts
import { toDnsDidLabel, fromDnsDidLabel, toDidDomainHost } from '@arcblock/did';

const label = toDnsDidLabel(did);
const decodedDid = fromDnsDidLabel(label);
const host = toDidDomainHost(did, 'did.abtnet.io');

DIDDomainEdgeRouter adds ownership checks to that relationship. A domain NFT binding proof contains the domain, DID, and asset information. Given a trusted asset-state snapshot supplied by the caller, the verifier checks the owner, whether the asset has been consumed, and whether data.domain or moniker matches. Signed endpoint records also bind the endpoint content and update time to prevent unauthorized replacement. Fetching chain state, refreshing after NFT transfers, and writing DNS records remain the integrating service's responsibility.

synthesizeDidDocument takes the next step, from finding an endpoint to knowing how to verify its identity. It builds a document from supplied public keys, rotation events, and service configuration. Signature verification relationships go into authentication, assertionMethod, capabilityInvocation, and capabilityDelegation. Supplying an X25519 public key also declares keyAgreement. Personal space, AFS, and Noise endpoints go into service.

These fields follow the verification relationships and service model in W3C DID Core. Login authentication and issuing assertions serve different purposes, for example. An integration needs to select the relationship for the operation rather than accepting every operation merely because a document contains a public key.

Domain resolution and proof of domain control also need separate checks. DIF Well Known DID Configuration uses a Domain Linkage Credential in /.well-known/did-configuration.json to prove the relationship between an origin and a DID. Our NFT binding proofs and DID Document synthesis have their own interfaces. External integrations need to check addressing, control proofs, and document formats individually.

Key rotation needs a verifiable handover

Key rotation gives device changes and key replacement a handover that a verifier can inspect. Pre-rotation adds protection by committing to the next key in advance: even if the current hot key is compromised, an attacker cannot freely substitute a key of their own to take over the identity.

An identity derived from a public key hash presents a practical problem. Once the public key changes, how does a verifier know the new key still represents the original identity? A statement signed only by the current hot key is insufficient for that threat: anyone who steals that key can sign the same statement.

KERI pre-rotation offers a useful approach. Commit to the next key beforehand, then require the replacement key to satisfy that commitment. We use that idea in our rotation events and chain protocol checks. The current event format is ArcBlock's own.

createPreRotationCommitment first normalizes the next public key to lowercase hex, then computes the commitment below. LP is shorthand for length-prefixed encoding: strings become UTF-8, each field is encoded as u32be(byte length) || field bytes, and u32be(index) is appended at the end. Lengths and the final index are unsigned 32-bit big-endian values. Key type and timestamp fields in these formulas are first converted to decimal strings. Field boundaries therefore do not depend on interpreting delimiters.

text
C_next = SHA3-256(
  LP("arcblock:did-pre-rotation:v1", keyType, normalizedPublicKey; index=0)
)

A rotation event contains did, an increasing seq, previousPublicKey, newPublicKey, newKeyType, an optional commitment for the next rotation, and rotatedAt. The old and new keys each sign the same domain-separated digest:

text
digest = SHA3-256(LP(
  "arcblock:did-key-rotation:v1",
  DID address, previousPublicKey, newPublicKey,
  newKeyType, nextKeyCommitment, rotatedAt;
  index=seq
))

previousKeySignature = Sign(oldKey, digest)
newKeySignature      = Sign(newKey, digest)

The verifier checks authorization by the old key and proof of possession of the new one. Starting with the binding between the initial public key and DID, verifyKeyRotationChain checks each hop for seq = i + 1, continuous public keys, nondecreasing timestamps, pre-commitments, and both signatures. It currently accepts at most 64 events. The key inputs are:

ts
const result = verifyKeyRotationChain({
  did,
  events,
  genesisPreRotationCommitment: trustedInitialCommitment,
  expectedCurrentPublicKey: presentedPublicKey,
});

if (!result.valid) throw new Error(result.reason);

The source of trustedInitialCommitment matters. A verifier needs a trusted initial commitment to establish that the first hop honored the key chosen beforehand. Without that input, the first hop still checks the initial public key's binding to the DID and both signatures, but lacks the prior constraint. An offline event chain establishes internal continuity in the history presented to it. Discovering the latest event and preventing rollback to an older state require a trusted update source.

Chain integration reuses account_migrate. When account data contains a valid nextKeyCommitment, migration checks the target public key against it before fees or state changes. A mismatch returns INVALID_PRE_ROTATION_KEY. Successful migration consumes and records the old commitment, and can advance the next commitment. The implementation also rejects commitments to the current key, retired keys, and previously consumed historical keys, preventing later migrations from being committed into a dead end.

The rotation-event interface currently supports the synchronous Ed25519, secp256k1, and Ethereum signing types. An asynchronous Passkey assertion cannot be inserted directly into that same call. Pre-commitment protects the case where the hot key is compromised but the independent next key remains safe. Recovery after losing the old key requires a separate recovery policy.

Selective disclosure: verify the claim and this presentation

A service that only needs to confirm adulthood can accept an issuer-signed over18=true while the holder keeps their name, identity document number, and other information private. Membership levels and training qualifications can work the same way. Each presentation supplies only the signed fields needed for the task, reducing the personal information the service collects and stores.

A conventional JWT signature covers the entire payload. Presenting the token exposes every field in it. SD-JWT, defined in RFC 9901, replaces selected fields with digests and supplies disclosure material as needed at presentation time.

The three interfaces in this round are signSdJwt, presentSdJwt, and verifySdJwt. In a hypothetical qualification credential, an issuer signs over18=true and a name, but the holder reveals only the former. Disclosure material and the token fit together like this:

text
disclosure = base64url(JSON([salt, claimName, claimValue]))
digest     = hex(SHA3-256(disclosure))

issuer JWT: { _sd: [digest, ...], _sd_alg: "sha3-256", cnf: { did: holderDid }, ... }
presentation: issuerJwt ~ selectedDisclosure ~ kbJwt

The implementation generates random salts for selectively disclosed fields. It also supports decoy digests, reducing what the number of digests reveals about the number of claims. The verifier recomputes each disclosure's digest, confirms it appears in the issuer-signed _sd, and rejects duplicate disclosures, overwrites of existing claims, and injection of reserved claims.

kbJwt is the holder's Key-Binding JWT for this presentation. It includes sd_hash for the presented content and can bind the recipient through aud and a challenge through nonce. Integrators supply the expected audience, a random challenge, and a trusted holder public key:

ts
const checked = await verifySdJwt(presentation, issuerPublicKey, {
  issuer: trustedIssuerDid,
  requireKeyBinding: true,
  holderPk: holderPublicKey,
  expectedAudience: verifierOrigin,
  expectedNonce: challenge,
  isStatusRevoked: (index) => !trustedStatusList.isActive(index),
});

// Read only verified claims; reject missing required claims under application policy.
if (!checked.valid || checked.disclosedClaims?.over18 !== true) {
  throw new Error('Adult credential required');
}

issuerPublicKey must come from an issuer accepted by the verifier and be used with a pinned trustedIssuerDid. The verifier binds the holder public key to the signed cnf.did. Applications must establish who is qualified to issue this kind of credential before accepting it. The checks then cover the issuer, the disclosed content, and the holder making this presentation. The recipient must generate the challenge, and the application must limit its reuse. A nonce inside a token alone cannot establish whether a proof has already been used.

In this age example, the issuer must sign over18 in advance. The current interface does not generate a range proof over a hidden birth date. Its SHA3-256/hex digests and cnf.did binding also need checking when integrating with RFC 9901 implementations. Field-level disclosure reduces the information handed over, while stable identifiers in the same issued credential can still make presentations linkable.

Credential status as a bit lookup

Issued credentials need a way to be revoked or suspended. A bitstring status list lets an issuer update the status of a batch while other valid credentials remain usable. Verifiers can retrieve status in bulk and check it locally within a cache window, reducing requests back to the issuer for each presentation.

A correct signature establishes that the content has not been altered. It remains correct after a qualification is revoked. W3C Bitstring Status List v1.0 describes publishing credential status in bulk, allowing a verifier to download and cache a list, then inspect the position assigned to a credential.

BitstringStatusList holds 131,072 entries by default. In single-bit mode, each entry represents active or revoked status, with a raw size of 16 KiB. Two-bit mode uses 0=ACTIVE, 1=REVOKED, and 2=SUSPENDED, with a raw size of 32 KiB. At issuance, a credential receives a list index, and the list reference and position are signed with it. This implementation can record those values in statusListCredential and statusListIndex. The verifier obtains a trusted list before reading the position.

ts
import { BitstringStatusList } from '@arcblock/did-ext';

const revoked = new BitstringStatusList(); // Default single-bit mode
revoked.revoke(13);
revoked.getRawBytes()[1] === 0b00000100; // true

const list = new BitstringStatusList({ bitSize: 2 });
list.suspend(1042);
list.isActive(1042); // false
list.reinstate(1042);
list.isActive(1042); // true
const encodedList = list.encode(); // gzip + multibase base64url

Take credential 13 in single-bit mode, with indices starting at 0. byteIndex = 13 >> 3 = 1 selects the second byte. bitOffset = 7 - (13 & 7) = 2 selects its bit. A bitwise OR with 1 << 2 marks it revoked:

text
Credential index: 8  9 10 11 12 13 14 15
Revoked status:   0  0  0  0  0  1  0  0
Second byte:      00000100

A lookup reads that byte, shifts it, and takes the lowest bit, giving O(1) time complexity. Revoking one credential changes its corresponding bit and requires updating and signing the list again; other valid credentials need no reissuance. Two-bit mode uses the same addressing idea, fitting four entries into each byte and adding room for temporary suspension.

Bitmaps have another useful property here. When most credentials remain active, long runs of zero bits compress well with gzip. A verifier downloads one list and can check the batch within the cache validity period without telling the issuer which credential is being presented each time. Fewer requests and less exposure through individual status queries come from the same design.

The example above shows local status operations. Across services, a verifier must first establish the publisher and validity period through the signed-list verification interface before trusting the bits. The SDK also supplies status digest tools for higher-level anchoring or comparison. Publishing, refreshing, and anchoring lists on-chain belong to the integrating system. The decoding path limits compressed input and caps the decompressed raw bitmap at 2 MiB, bounding expansion of untrusted compressed content.

Bulk lists reduce the usage trail created by querying an issuer for every credential, but freshness still depends on the refresh policy. A five-minute cache means accepting that newly revoked credentials may remain unknown during that window. Gzip size varies with the distribution of states; 16 KiB is the raw size of the default single-bit list. The current signed wrapper and two-bit multi-state representation use this implementation's conventions, so third-party credential systems need to verify format compatibility.

Agent delegation: signatures are only part of the check

A user can give an agent short-lived authority for a particular operation. If the task passes to another agent, the receiving service can still inspect whom it acts for, how that authority reached it, and whether the requested operation is permitted. The boundary travels with the delegation.

On-chain delegate serves delegations that need chain-state constraints. For short-lived agent calls, the unified verification interface can accept an offline DelegationToken and trace the signed authorization chain Human → Agent A → Agent B. It uses progressively narrower authority, similar to UCAN's capability attenuation model. The current token fields and encoding follow ArcBlock's own conventions.

The delegation payload contains iss, sub, pk, ops, optional deny, time fields, and an optional parent delegation. The verifier checks that the signing public key derives iss, that the parent token's sub equals the child token's iss, that the child's validity period stays within the parent's, and that the child's operations are allowed by the parent grant. A child grant cannot drop inherited deny rules. The default maximum delegation depth is 5.

This gives the receiving service evidence of the current actor's identity and of the upstream authorization it relies on. The resource service must still map ops to actual access rules. Permission to read and permission to read only one directory impose different constraints; directory restrictions must be enforced against the resource access path. Verification needs to be checked with real permitted requests and requests that exceed the grant.

verifyArcPrincipal combines direct sessions, delegation, rotation proofs, and status checks in one entry point. A typical integration looks like this. The caller supplies identity and proofs in envelope; the verifier supplies the trusted list and request-binding values.

ts
const principal = await verifyArcPrincipal(envelope, {
  mode: 'strict',
  requiredScope: 'fg:afs:read',
  expectedAudience: serviceAudience,
  expectedNonce: challenge,
  isRevokedIndex: (index) => !trustedStatusList.isActive(index),
});

if (!principal.valid) throw new Error(principal.reason);

The three modes deserve explicit treatment in migration configuration:

ModeBehavior without cryptographic proof
permissiveCan accept certain legacy agent identifiers or checksum-valid DIDs with an unverified warning; operations requiring scope still need proof
hybridRequires a checksum-valid DID; can accept it with a warning when no proof is supplied and no scope is required
strictRequires cryptographic proof; checks scope, request binding, and status according to integration options

The returned verificationLevel distinguishes legacy_string, checksum_only, cryptographic, and delegated_cryptographic. Applications can observe calls still using legacy identifiers, then tighten individual entry points. Revocation checks have data only when a callback and the corresponding index are supplied. Binding a delegated call to the current request also requires an actor-signed session proof, with the service checking audience, challenge, and usage policy.

Where Passkeys, subaddresses, and encrypted sessions connect

These foundations also support more immediate uses: signing through a device authenticator, organizing account addresses by purpose, and establishing encrypted sessions with identity-verified peers. The following interfaces handle signing, address partitioning, and communication, and can be combined as an integration needs.

Passkey JWT splits the browser authenticator interaction into two stages. It first generates headerB64.bodyB64, then uses getChallenge to obtain this implementation's SHA3 pre-signing digest. Once the browser receives a WebAuthn assertion, assemble places authenticatorData, clientDataJSON, and signature into the token's third segment. Verification checks the challenge's binding to the JWT content and the assertion signature. The current PasskeySigner also enforces clientData.type = webauthn.get and the user-presence flag UP, and requires the user-verification flag UV by default. The general chain signature-verification path has no shared website origin or RP ID, and does not check origin, rpIdHash, or signature counters. Websites using these JWTs for login must additionally check the expected origin, RP ID, and registered credential ownership under their own policies.

The authenticator actually signs authenticatorData || SHA-256(clientDataJSON), as specified by WebAuthn. The JWT digest enters the challenge, which enters clientDataJSON; those two hash layers have different jobs. Our earlier wallet article covers key protection, authorization displays, and recovery. This work supplies the SDK connection to the signing process.

Subaddresses also have two layers. Public identity partitioning uses a fixed domain-separation key and length-prefixed encoding:

text
seed = HMAC-SHA256(
  key = "arcblock:subaccount:v1",
  message = LP(scope, parentIdentity; index)
)
address = fromPublicKeyHash(seed, type)

Here the key is a public domain-separation constant, and the input can itself be a public identity. The result provides deterministic address partitioning; it creates no hardware-held secret. By contrast, fromChainDid(chainDid, rootSk, type, index) derives independent chain wallets from a software root secret and handles actual private key material. The two derivations have different security assumptions. When cross-chain account relationships need disclosure, association-proof interfaces also supply a challenge and expiration time.

For encrypted communication, @arcblock/did-noise provides DID-bound Noise sessions and transport tools, including Ed25519/X25519 conversion interfaces. The Noise Protocol Framework defines handshake patterns and key agreement. Authorization policy after the handshake still determines which resources the identity may access. Signature relationships, key agreement relationships, and service endpoints each have their own place in a DID Document, rather than being treated as one all-purpose public key.

Check the whole request

A real integration can follow one request all the way through. Does the DNS-safe label recover the original DID? Do the DID Document's public keys match their intended uses? Does the rotation history lead back to a trusted initial identity? Has the agent's delegated authority narrowed? Are the disclosed qualification fields sufficient without exposing unnecessary data? Is the status list trusted and fresh enough? Does the resource service accept the permitted request and reject the one that exceeds its authority?

That is the engineering value of this refresh. Resolution, rotation, disclosure, delegation, and status each expose inputs and results that can be checked. Applications can adopt them step by step and identify a specific reason when verification fails. Keeping a DID useful through changes of machine, key, and agent depends on verifying those handovers.

These specifications supply data models and design foundations for their respective problems. The sections above identify ArcBlock-specific encodings and integration conventions; interoperability with third parties still requires checking the concrete formats.