Building a tamper-evident land registry
How Milkiyat uses hash chaining, AES-256-GCM and RSA signatures so that any edit to a land record, even one made directly in the database, is caught.
Land records are a classic target for fraud. If someone with database access can quietly change an owner's name, the paper trail is gone. For Milkiyat, my blockchain land registry project, the goal was simple to state and harder to build: any change to a record, by anyone, must be detectable.
This post walks through the three layers that make that work, and the single verification pass that checks all of them.
1. Hash chaining: every block depends on the one before it
Each land record is stored as a block. A block's hash covers its own fields and the hash of the previous block, so changing any block breaks every link after it.
The detail that matters is what gets hashed. Milkiyat builds a canonical JSON string from the plaintext values, with sorted keys so the same data always produces the same bytes:
def _canonical_block_string(index, timestamp, land_id, owner, area, location, previous_hash):
payload = {
"index": index,
"timestamp": timestamp,
"land_id": land_id,
"owner": owner,
"area": float(area), # SQLite REAL always round-trips as float
"location": location,
"previous_hash": previous_hash,
}
return json.dumps(payload, sort_keys=True)
def compute_hash(*fields) -> str:
return hashlib.sha256(_canonical_block_string(*fields).encode("utf-8")).hexdigest()
Two small choices here prevent real bugs. sort_keys=True makes the hash independent of dictionary order. Casting area to float avoids a false alarm when an integer goes into SQLite and comes back as 10.0.
2. Encrypting sensitive fields at rest
Owner names and locations are personal data, so they're encrypted before they touch disk with AES-256-GCM, using a fresh 12-byte nonce for every field:
def encrypt_field(master_key: bytes, plaintext: str) -> str:
aesgcm = AESGCM(master_key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(nonce + ct).decode("utf-8")
GCM is authenticated encryption: if anyone flips a single bit of the stored ciphertext, decryption fails outright instead of producing garbage. That's the first tripwire.
Notice that the hash in step 1 is computed from the plaintext, not the ciphertext. Suppose an attacker has the key and re-encrypts a forged owner name so the ciphertext is perfectly valid. The forged plaintext still has to match the stored hash, and it won't.
3. Signing every block
Hashes prove a record hasn't changed. They don't prove who approved it. In Milkiyat only a Surveyor can approve a registration or transfer, and approval signs the block's hash with that Surveyor's personal RSA-2048 key using PSS padding and SHA-256:
signature = private_key.sign(
data,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256(),
)
Each private key is stored encrypted with its owner's password and only decrypted in memory for the length of their session. A Surveyor can't later deny approving a transfer, and nobody else can approve one in their name.
Roles also enforce separation of duties: the Admin manages accounts and runs verification but deliberately cannot create or sign records.
Verification: three checks in one pass
Anyone with the right role can run Verify Chain Integrity at any time. For every block it:
- Decrypts the fields. If that fails, the ciphertext was corrupted or the key is wrong.
- Recomputes the hash from the plaintext and compares it to the stored hash, which catches edited data.
- Checks that
previous_hashmatches the preceding block, which catches inserted, deleted or reordered blocks. - Verifies the RSA signature against the signer's stored public key, which catches forged approvals.
recomputed = compute_hash(row["idx"], row["timestamp"], row["land_id"],
owner, row["area"], location, row["previous_hash"])
if recomputed != row["hash"]:
issues.append(VerificationIssue(row["idx"], "Hash mismatch - block content has been altered"))
elif i > 0 and row["previous_hash"] != rows[i - 1]["hash"]:
issues.append(VerificationIssue(row["idx"], "Broken link - previous_hash does not match preceding block"))
elif row["signed_by"] and not security.verify_signature(pub, row["hash"].encode(), row["signature"]):
issues.append(VerificationIssue(row["idx"], "Invalid signature"))
It reports exactly which block failed and why, rather than a single pass/fail.
Testing it the way an attacker would
I drove the whole app end to end through its real GUI: create users, submit a request as a Registrar, approve and sign it as a Surveyor, then verify as Admin. Then I edited a record directly in the SQLite database, bypassing the app entirely. The next verification run flagged that block immediately.
Other security details
- Passwords: PBKDF2-HMAC-SHA256 with 200,000 iterations and a random salt per user, compared with
hmac.compare_digestto avoid timing leaks. - Two-factor login: RFC 6238 TOTP written from scratch, compatible with Google Authenticator, accepting one 30-second step of clock drift.
- Audit log: every login, failed login, request, approval, rejection and verification run is recorded with time, actor and outcome.
What I'd change before real-world use
Milkiyat is a single-machine reference implementation, and I'm upfront about its limits:
- Single node. One compromised machine could rewrite history. Production needs a permissioned multi-node ledger such as Hyperledger Fabric, with real consensus.
- Key storage. The AES master key is a local file. It belongs in an HSM or a managed KMS with rotation and access logging.
- MFA by default. Two-factor is built but opt-in. Signing roles should require it.
- An independent audit. It follows standard practice (PBKDF2, AES-GCM, RSA-PSS, TOTP) but hasn't been reviewed by a third party.