identityvm: judge credential expiry at block time, and stop letting a missing

proof satisfy RequireZKProofs

Two defects.

1. Block.verifyCredential compared cred.ExpirationDate against time.Now() inside
   block verification. The verdict therefore depended on when a node happened to
   verify: the same block is valid before the expiry instant and invalid after,
   so two validators either side of it disagree. Worse, it is unconditional on
   replay — a node bootstrapping the chain rejects every historical block whose
   credentials have since expired, which after enough time is all of them.
   Proven: a block timestamped 48h ago carrying a credential that was live then
   failed verification. Now compared against b.BlockTimestamp, the one clock
   every node agrees on, already bounded against the parent and local time
   earlier in Verify.

2. VerifyCredential gated the ZK requirement on the presence of the thing it
   requires: `if vm.config.RequireZKProofs && cred.Proof != nil`. A credential
   carrying no proof object skipped the branch and returned (true, nil) on a
   chain configured to require proofs. Proven: VerifyCredential returned true for
   a proofless credential with RequireZKProofs set. The requirement now holds
   whenever it is configured.

Tests: TestCredentialExpiryIsJudgedAtBlockTime,
TestRequireZKProofsCannotBeSkippedByOmittingTheProof.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 16:48:10 -07:00
co-authored by Hanzo Dev
parent 3d38bef46f
commit 14b1b66956
3 changed files with 110 additions and 6 deletions
+7 -2
View File
@@ -121,8 +121,13 @@ func (b *Block) verifyCredential(cred *Credential) error {
return errors.New("too many claims")
}
// Verify expiration is in future
if time.Now().After(cred.ExpirationDate) {
// The credential must be unexpired AS OF THIS BLOCK. Comparing against the
// wall clock would make the verdict depend on when a node happens to verify:
// the same block is valid before the expiry and invalid after it, and a node
// replaying history during bootstrap rejects every block whose credentials
// have since expired. The block timestamp is the only clock every node agrees
// on, and Verify has already bounded it against the parent and local time.
if time.Unix(b.BlockTimestamp, 0).After(cred.ExpirationDate) {
return errors.New("credential already expired")
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package identityvm
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
)
// credentialExpiringAt builds a self-issued credential with the given expiry.
func credentialExpiringAt(expiry time.Time) *Credential {
return &Credential{
ID: ids.GenerateTestID(),
Type: []string{"VerifiableCredential"},
Issuer: ids.GenerateTestID(),
Subject: ids.GenerateTestID(),
IssuanceDate: expiry.Add(-time.Hour),
ExpirationDate: expiry,
Claims: map[string]interface{}{"name": "test"},
Status: CredentialActive,
}
}
// TestCredentialExpiryIsJudgedAtBlockTime pins credential expiry to the block
// timestamp rather than the wall clock. Against time.Now() the same block is
// valid before the expiry and invalid after it, so two nodes verifying either
// side of that instant disagree — and a node replaying history during bootstrap
// rejects every historical block whose credentials have since expired, which is
// every one of them.
func TestCredentialExpiryIsJudgedAtBlockTime(t *testing.T) {
require := require.New(t)
vm := setupTestVM(t)
defer vm.Shutdown(context.Background())
// A block from the past carrying a credential that was valid then and has
// since expired. Replaying it must still verify.
blockTime := time.Now().Add(-48 * time.Hour)
historical := &Block{
BlockHeight: 1,
BlockTimestamp: blockTime.Unix(),
Credentials: []*Credential{credentialExpiringAt(blockTime.Add(time.Hour))},
vm: vm,
}
require.NoError(historical.verifyCredential(historical.Credentials[0]),
"a credential live at the block's timestamp must verify when replayed later")
// A block that carries a credential already expired at its own timestamp is
// still refused.
stale := &Block{
BlockHeight: 1,
BlockTimestamp: blockTime.Unix(),
Credentials: []*Credential{credentialExpiringAt(blockTime.Add(-time.Hour))},
vm: vm,
}
require.Error(stale.verifyCredential(stale.Credentials[0]))
}
// TestRequireZKProofsCannotBeSkippedByOmittingTheProof proves the ZK requirement
// is not gated on the presence of the thing it requires. A credential with no
// proof object verified under RequireZKProofs, so the setting was bypassed by
// simply not supplying a proof.
func TestRequireZKProofsCannotBeSkippedByOmittingTheProof(t *testing.T) {
require := require.New(t)
vm := setupTestVM(t)
defer vm.Shutdown(context.Background())
vm.config.RequireZKProofs = true
noProof := credentialExpiringAt(time.Now().Add(time.Hour))
emptyProof := credentialExpiringAt(time.Now().Add(time.Hour))
emptyProof.Proof = &CredentialProof{Type: "zk"}
withProof := credentialExpiringAt(time.Now().Add(time.Hour))
withProof.Proof = &CredentialProof{Type: "zk", ZKProof: []byte("proof-bytes")}
vm.mu.Lock()
for _, c := range []*Credential{noProof, emptyProof, withProof} {
vm.credentials[c.ID] = c
}
vm.mu.Unlock()
ok, err := vm.VerifyCredential(noProof.ID)
require.False(ok, "a credential with no proof must not satisfy RequireZKProofs")
require.ErrorIs(err, errInvalidProof)
ok, err = vm.VerifyCredential(emptyProof.ID)
require.False(ok)
require.ErrorIs(err, errInvalidProof)
ok, err = vm.VerifyCredential(withProof.ID)
require.NoError(err)
require.True(ok)
}
+5 -4
View File
@@ -636,12 +636,13 @@ func (vm *VM) VerifyCredential(credID ids.ID) (bool, error) {
return false, errCredentialRevoked
}
// Verify ZK proof if required
if vm.config.RequireZKProofs && cred.Proof != nil {
if len(cred.Proof.ZKProof) == 0 {
// When the chain requires ZK proofs, a credential must carry one. Gating the
// requirement on cred.Proof != nil made the requirement optional: a credential
// with no proof object at all skipped the check and verified.
if vm.config.RequireZKProofs {
if cred.Proof == nil || len(cred.Proof.ZKProof) == 0 {
return false, errInvalidProof
}
// Would verify ZK proof here
}
return true, nil