Security

Hash chain

A tamper-evident audit log: each entry's SHA-256 covers the one before, so a changed, removed or reordered entry is found.

Version 1.0.0 · 3 functions · 1 crate

#audit#logs#compliance#integrity

Functions

What use hash_chain.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use hash_chain_append, hash_chain_verify from hash_chain (Quillo 0.6).

hash_chain_append(chain: list, data: any) → anyor an error

The next entry for chain (the entries so far, oldest first; [] for a new log) holding data: { index, at, data, prev, hash }, at in milliseconds since 1970, prev the last entry's hash (64 zeros for the first). Store it as it is, after the others; only its own fields and the last entry are read, so a long log can pass only its last entry.

hash_chain_verify(chain: list) → any

Checks a whole chain, oldest first: { ok, length, broken_at, reason }. broken_at is the index of the first entry that was changed, removed, put in another place or added in the middle, null when all is well. A chain cut at its end still checks: keep the last hash elsewhere (another database, a daily email) to find that.

hash_chain_hash(entry: any) → str

The hash an entry should have, worked out again from its index, at, data and prev: to check one entry alone.

Get it

$ quillo install hash_chain               # hash_chain.qwer into this folder$ quillo install hash_chain --example     # and its example, hash_chain.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/hash_chain.qwer$ curl -O https://quillolang.org/plugins/files/hash_chain.qwe

Then, in your program, all of it or only what you call:

main.qwe
use hash_chain.qweruse hash_chain_append, hash_chain_verify from hash_chain      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
sha2 = "0.10"

Example

hash_chain.qwe
// An audit log of admin actions in which no past entry can be changed unseen.////   quillo run hash_chain.qwe      then open http://localhost:8080/demouse "hash_chain.qwer"fn log_action(log: list, who: str, action: str) = [...log, hash_chain_append(log, { who, action })?]route GET /demo {    let log = log_action(log_action(log_action([], "ada", "refund order 1042"), "grace", "change price of B7"), "ada", "delete user 77")    write_file("data/audit.json", json.stringify(log, true))    let stored = json.parse(read_file("data/audit.json")?)?    // Someone edits the second entry in the file afterwards.    let edited = [stored[0], { ...stored[1], data: { who: "grace", action: "change price of B8" } }, stored[2]]    {        entries: len(stored),        last_hash: last(stored).hash,        intact: hash_chain_verify(stored),        edited: hash_chain_verify(edited),        removed: hash_chain_verify([stored[0], stored[2]]).reason,    }}
$ quillo run hash_chain.qwe$ curl 'localhost:8080/demo'

Source

hash_chain.qwer
//! A tamper-evident audit log: each entry's SHA-256 covers the one before, so changing, removing or reordering any past entry is found; for admin actions, payments and consent records.//! version: 1.0.0//!//! [dependencies]//! sha2 = "0.10"use serde_json::{json, Map, Value};use sha2::{Digest, Sha256};const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";/// The value with its maps' keys sorted, all the way down, so the same entry always hashes the same.fn canonical(v: &Value) -> Value {    match v {        Value::Object(m) => {            let mut keys: Vec<&String> = m.keys().collect();            keys.sort();            let mut out = Map::new();            for k in keys {                out.insert(k.clone(), canonical(&m[k]));            }            Value::Object(out)        }        Value::Array(a) => Value::Array(a.iter().map(canonical).collect()),        other => other.clone(),    }}fn digest(index: &Value, at: &Value, data: &Value, prev: &Value) -> String {    let body = canonical(&json!({ "index": index, "at": at, "data": data, "prev": prev }));    // Keys in order, written without spaces: the same text in any language that sorts keys.    let text = serde_json::to_string(&body).unwrap_or_default();    Sha256::digest(text.as_bytes()).iter().map(|b| format!("{:02x}", b)).collect()}fn now_ms() -> i64 {    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as i64)}/// The next entry for `chain` (the entries so far, oldest first; `[]` for a new log) holding `data`: `{ index, at, data, prev,/// hash }`, `at` in milliseconds since 1970, `prev` the last entry's hash (64 zeros for the first). Store it as it is, after/// the others; only its own fields and the last entry are read, so a long log can pass only its last entry.pub fn hash_chain_append(chain: Vec<serde_json::Value>, data: serde_json::Value) -> Result<Value, String> {    let (index, prev) = match chain.last() {        None => (0, GENESIS.to_string()),        Some(last) => {            let i = last["index"].as_i64().ok_or("the last entry has no index: is this a chain hash_chain_append made?")?;            let h = last["hash"].as_str().ok_or("the last entry has no hash")?;            (i + 1, h.to_string())        }    };    let at = now_ms();    let hash = digest(&json!(index), &json!(at), &data, &json!(prev));    Ok(json!({ "index": index, "at": at, "data": data, "prev": prev, "hash": hash }))}/// Checks a whole chain, oldest first: `{ ok, length, broken_at, reason }`. `broken_at` is the index of the first entry/// that was changed, removed, put in another place or added in the middle, null when all is well. A chain cut at its end/// still checks: keep the last hash elsewhere (another database, a daily email) to find that.pub fn hash_chain_verify(chain: Vec<serde_json::Value>) -> Value {    let mut prev = GENESIS.to_string();    for (i, e) in chain.iter().enumerate() {        let fail = |reason: String| json!({ "ok": false, "length": chain.len(), "broken_at": i, "reason": reason });        if e["index"].as_i64() != Some(i as i64) {            return fail(format!("entry {} has index {}: one was removed or moved", i, e["index"]));        }        if e["prev"].as_str() != Some(prev.as_str()) {            return fail(format!("entry {} does not follow the one before it", i));        }        let h = digest(&e["index"], &e["at"], &e["data"], &e["prev"]);        if e["hash"].as_str() != Some(h.as_str()) {            return fail(format!("entry {} was changed after it was written", i));        }        prev = h;    }    json!({ "ok": true, "length": chain.len(), "broken_at": null, "reason": null })}/// The hash an entry should have, worked out again from its `index`, `at`, `data` and `prev`: to check one entry alone.pub fn hash_chain_hash(entry: serde_json::Value) -> String {    digest(&entry["index"], &entry["at"], &entry["data"], &entry["prev"])}
Thread
711Useful? Like it, or add to its thread below.

Thread 0 posts

On Hash chain: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.