Files
chains/identityvm/block.go
T
zeekayandHanzo Dev 14b1b66956 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]>
2026-07-29 16:48:10 -07:00

236 lines
5.5 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package identityvm
import (
"context"
"crypto/sha256"
"errors"
"time"
"github.com/luxfi/consensus/core/choices"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// Block represents a block in the IdentityVM chain
type Block struct {
ParentID_ ids.ID `json:"parentId"`
BlockHeight uint64 `json:"height"`
BlockTimestamp int64 `json:"timestamp"`
Credentials []*Credential `json:"credentials"`
Revocations []*RevocationEntry `json:"revocations,omitempty"`
Identities []*Identity `json:"identities,omitempty"`
StateRoot []byte `json:"stateRoot"`
// Cached values
ID_ ids.ID
bytes []byte
status choices.Status
vm *VM
}
// ID returns the block ID
func (b *Block) ID() ids.ID {
if b.ID_ == ids.Empty {
b.ID_ = b.computeID()
}
return b.ID_
}
// computeID computes the block ID as the hash of the canonical ZAP wire.
// The wire commits to every block field, so identical logical blocks yield
// identical IDs and any field change moves the ID.
func (b *Block) computeID() ids.ID {
return ids.ID(sha256.Sum256(b.Bytes()))
}
// ParentID returns the parent block ID
func (b *Block) ParentID() ids.ID {
return b.ParentID_
}
// Parent is an alias for ParentID for compatibility
func (b *Block) Parent() ids.ID {
return b.ParentID_
}
// Height returns the block height
func (b *Block) Height() uint64 {
return b.BlockHeight
}
// Timestamp returns the block timestamp
func (b *Block) Timestamp() time.Time {
return time.Unix(b.BlockTimestamp, 0)
}
// Status returns the block status
func (b *Block) Status() uint8 {
return uint8(b.status)
}
// Verify verifies the block
func (b *Block) Verify(ctx context.Context) error {
// Verify height
if b.BlockHeight == 0 && b.ParentID_ != ids.Empty {
return errors.New("invalid genesis block")
}
// Verify timestamp is not too far in future
if b.BlockTimestamp > time.Now().Unix()+60 {
return errors.New("block timestamp too far in future")
}
// Verify parent exists and heights are consecutive
if b.BlockHeight > 0 {
parent, err := b.vm.GetBlock(ctx, b.ParentID_)
if err != nil {
return err
}
if b.BlockHeight != parent.Height()+1 {
return errors.New("non-consecutive block heights")
}
if b.BlockTimestamp < parent.Timestamp().Unix() {
return errors.New("block timestamp before parent")
}
}
// Verify all credentials
for _, cred := range b.Credentials {
if err := b.verifyCredential(cred); err != nil {
return err
}
}
return nil
}
func (b *Block) verifyCredential(cred *Credential) error {
// Verify issuer exists
_, err := b.vm.GetIssuer(cred.Issuer)
if err != nil && !b.vm.config.AllowSelfIssue {
return err
}
// Verify claims count
if len(cred.Claims) > b.vm.config.MaxClaims {
return errors.New("too many claims")
}
// 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")
}
return nil
}
// Accept accepts the block
func (b *Block) Accept(ctx context.Context) error {
b.status = choices.Accepted
b.vm.mu.Lock()
defer b.vm.mu.Unlock()
// Update VM state
b.vm.lastAccepted = b
b.vm.lastAcceptedID = b.ID()
// Save last accepted
id := b.ID()
if err := b.vm.db.Put(lastAcceptedKey, id[:]); err != nil {
return err
}
// Save block
blockBytes := b.Bytes()
if blockBytes == nil {
return errors.New("failed to serialize block")
}
if err := b.vm.db.Put(id[:], blockBytes); err != nil {
return err
}
// Process credentials
for _, cred := range b.Credentials {
b.vm.credentials[cred.ID] = cred
// Persist credential (native ZAP wire)
credBytes := marshalCredential(cred)
credKey := append(credentialPrefix, cred.ID[:]...)
b.vm.db.Put(credKey, credBytes)
// Remove from pending
for i, pending := range b.vm.pendingCreds {
if pending.ID == cred.ID {
b.vm.pendingCreds = append(b.vm.pendingCreds[:i], b.vm.pendingCreds[i+1:]...)
break
}
}
}
// Process revocations
for _, rev := range b.Revocations {
b.vm.revocations[rev.CredentialID] = rev
// Update credential status
if cred, ok := b.vm.credentials[rev.CredentialID]; ok {
cred.Status = CredentialRevoked
}
}
// Process new identities
for _, identity := range b.Identities {
b.vm.identities[identity.ID] = identity
}
// Remove from pending blocks
delete(b.vm.pendingBlocks, b.ID())
b.vm.log.Info("Block accepted",
log.Uint64("height", b.BlockHeight),
log.String("id", b.ID().String()),
log.Int("credentials", len(b.Credentials)),
)
return nil
}
// Reject rejects the block
func (b *Block) Reject(ctx context.Context) error {
b.status = choices.Rejected
b.vm.mu.Lock()
defer b.vm.mu.Unlock()
// Remove from pending blocks
delete(b.vm.pendingBlocks, b.ID())
return nil
}
// Bytes returns the block bytes
func (b *Block) Bytes() []byte {
if b.bytes != nil {
return b.bytes
}
bytes, err := b.Marshal()
if err != nil {
return nil
}
b.bytes = bytes
return bytes
}