Coverage 74.4% -> 99.7%. The two statements left are boot gates that
cannot fail by construction: RegisterService on a service type this
package defines, and fee.Validate on the floor chain.Floor returns.
The tests hold keys, because the chain now requires signatures: a party
signs its own record and the harness submits it, which is the shape a
client has to follow. Each defect the rewrite closed has a test named for
it — an attacker's block claiming a victim's DID, a revocation naming a
public id, a credential naming an issuer it cannot sign for, an allowlist
that admits nobody it does not name, a signature made for another chain,
a restart that must hold what the chain accepted, and a block whose
records reference each other.
Health and getStatus answered an error HealthCheck cannot return.
Every authorization on I-Chain was "the request said so". RevokeCredential
compared a caller-supplied revokerID against the credential's issuer and
subject, both of which GetCredential publishes — so revoking anyone's
credential was pasting their id into the request. RegisterIssuer checked
nothing and stamped Status "active", while TrustedIssuers was loaded and
never read: an allowlist that allowed everything. VerifyCredential's one
test on a proof was len(ZKProof) != 0. Nothing in the package verified a
signature; nothing was signed.
Records now carry an ML-DSA-65 signature over their content, checked
against the key the chain holds, with the chain's binding —
sha256(ChainID ‖ NetworkID), not on the wire — as the signing context. An
identity's id is the hash of its key and an issuer's is too, so a record
names a key its holder proved; a credential's is the hash of what it says.
A block's Identities and Revocations were verified by NOTHING. Verify
looped over Credentials alone, and Publish applied all three — so a peer's
block naming {victim's id, attacker's key} took over the victim's DID and
one naming any credential revoked it. Nothing else produced either list,
and Write persisted neither, so the takeover was invisible on disk.
Underneath that: CreateIdentity, RegisterIssuer and RevokeCredential wrote
straight to the base database on whichever node received the call, so no
two nodes held the same identity set — and verifyCredential consulted that
node-local issuer map, which makes block validity a property of which node
you ask. All four kinds are now block-carried, verified by one predicate
that assembly and consensus both ask, written in the block's commit, and
rebuilt from the records at boot. Nothing rebuilt them before: a restarted
node held only what genesis named and answered "unknown identity" for
every identity it had itself accepted.
Also:
A credential's lifetime came from a caller-supplied int64 added to the
wall clock with no sign check, and Take(0) returns the whole queue, and
Reject does not drop from it. One negative TTL therefore rode in every
block the node proposed, was refused by every node including itself, and
could not be removed: a permanent halt for one fee-paying call.
Verify required height == parent+1 and nothing more, so a block whose
parent was a buried ancestor verified; Accept re-checks the tip now,
because the tip moves between the two.
ParseBlock never tracked what it parsed and SetPreference discarded it,
so a follower could verify the first block of a run and not the second.
ParseGenesis stamped a genesis naming no timestamp with the wall clock —
a different genesis id per node and per restart — directly under the
comment explaining why that must not happen.
Nested frames did not reject trailing bytes, so one logical block had
unboundedly many ids; unpackStrings truncated silently on a bad length;
unpackMetadata dropped an unpaired string; parseClaims answered "no
claims" for a blob that would not decode, which passes the claims bound
trivially.
ResolveIdentity scanned every identity and returned whichever of two
matching DIDs the map reached first. A DID is derived from the id now,
so it is a lookup.
GetCredential wrote cred.Status under a READ lock. A credential's state
is derived from the revocation set and its expiry; it is not a field.
Gone with it: Controllers, Services, Updated and StateRoot, none of which
anything wrote; a CredentialProof carried on the wire whose only check was
its length; and a Status the chain kept in step with two other answers.