sealchain

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 5, 2026 License: AGPL-3.0 Imports: 16 Imported by: 0

README

sealchain

Go Reference

sealchain is a Go library for append-only, tamper-evident audit logs backed by SHA-256 hash chains and Ed25519 signatures.

Why sealchain

  • Dual-mechanism integrity — a SHA-256 hash chain proves ordering and continuity within a log; Ed25519 signatures prove actor authenticity. Each mechanism catches what the other cannot.
  • Domain extensibility — define your own event types and payload fields. sealchain has no opinion about what you log.
  • JSONL on disk — one JSON object per line. Human-readable, greppable, no database required.
  • Log rotation — split logs across files with unbroken cryptographic chain. CFR Part 11 compliant.

Quick Start

go get github.com/parallelhours/sealchain@latest
package main

import (
    "crypto/ed25519"
    "crypto/rand"
    "encoding/binary"
    "fmt"
    "log"

    "github.com/mr-tron/base58"
    sealchain "github.com/parallelhours/sealchain"
)

// Define your own event types.
const EventDocumentStored sealchain.EventType = "DOCUMENT_STORED"

// actor implements sealchain.Signer using Ed25519.
type actor struct {
    did     string
    privKey ed25519.PrivateKey
}

func (a *actor) Sign(msg []byte) ([]byte, error) {
    return ed25519.Sign(a.privKey, msg), nil
}

func newActor() (*actor, error) {
    pub, priv, err := ed25519.GenerateKey(rand.Reader)
    if err != nil {
        return nil, err
    }
    buf := make([]byte, binary.MaxVarintLen64+len(pub))
    n := binary.PutUvarint(buf, 0xed)
    copy(buf[n:], pub)
    did := "did:key:z" + base58.Encode(buf[:n+len(pub)])
    return &actor{did: did, privKey: priv}, nil
}

func main() {
    a, err := newActor()
    if err != nil {
        log.Fatal(err)
    }

    l := sealchain.NewLog("/var/log/myapp/audit-log.000.jsonl")

    err = l.Append(sealchain.Entry{
        Event:  EventDocumentStored,
        Domain: sealchain.DomainEntry{"document": "report-q1.pdf", "user": "alice"},
    }, a.did, a)
    if err != nil {
        log.Fatal(err)
    }

    if err := l.Verify(); err != nil {
        log.Fatal("log integrity check failed:", err)
    }
    fmt.Println("log verified OK")
}

Log Rotation

Rotate logs while preserving cryptographic chain integrity:

// Rotate to next generation (e.g., audit-log.000.jsonl -> audit-log.001.jsonl)
newLog, err := log.Rotate(sealchain.RotationManual, actorDID, signer)
if err != nil {
    log.Fatal(err)
}
// newLog is the next generation log (audit-log.001.jsonl)

Logs use numbered naming (audit-log.000.jsonl, audit-log.001.jsonl). Each rotation writes a terminus entry (end of old log) and genesis entry (start of new log), cross-linked by fingerprints.

Verify the entire chain across rotated logs:

if err := sealchain.VerifyChain("/var/log/myapp", "audit-log"); err != nil {
    log.Fatal("chain verification failed:", err)
}

sealcheck CLI

Verify an audit log file from the command line — no code required:

go install github.com/parallelhours/sealchain/cmd/sealcheck@latest

# Verify single log
sealcheck verify /var/log/myapp/audit-log.000.jsonl
# OK: 42 entries verified

# Verify cross-log chain (with rotation)
sealcheck verify-chain /var/log/myapp audit-log
# verify-chain: chain valid

Exit codes: 0 = valid, 1 = tampered or corrupt, 2 = usage error.

Documentation

  • Core Concepts — hash chains, genesis sentinel, signature scope, and security guarantees
  • Integration Guide — embedding sealchain in your Go project
  • Log Rotation — rotation design, CFR Part 11 compliance, API reference
  • Licensing — AGPL v3 and commercial license options

License

sealchain is dual-licensed:

  • AGPL v3 — free for open source use. If you distribute or run a service that includes sealchain, your project must also be licensed under AGPL v3. See LICENSE.
  • Commercial license — for closed-source products or SaaS deployments without copyleft obligations. Contact support@parallelhours.io.

See docs/licensing.md for details.

Documentation

Overview

Package sealchain provides an append-only, tamper-evident audit log backed by a newline-delimited JSON file. Each entry is signed with Ed25519 and cryptographically linked to its predecessor via SHA-256, forming a chain where any deletion, insertion, or modification of a committed entry is detectable by Verify.

Security model

The hash chain is computed over raw on-disk bytes, not re-marshaled Go structs. Any byte-level change to a committed entry — including whitespace — breaks the chain. Signatures cover the entire entry JSON with the Signature field set to "" before signing, preventing signature transplant attacks. The public key used to verify each entry is resolved directly from the ActorDID embedded in that entry, so every entry is cryptographically bound to the key that signed it.

Quick start

// 1. Define your event types.
const EventUserLogin sealchain.EventType = "user.login"

// 2. Implement Signer for your key material.
type mySigner struct{ key ed25519.PrivateKey }
func (s mySigner) Sign(msg []byte) ([]byte, error) {
    return ed25519.Sign(s.key, msg), nil
}

// 3. Derive a did:key DID from your public key.
//    Format: "did:key:z" + base58(uvarint(0xed) + publicKeyBytes)

// 4. Create or reopen a log.
log := sealchain.NewLog("audit.jsonl")

// 5. Append entries.
err := log.Append(sealchain.Entry{
    Event:  EventUserLogin,
    Domain: sealchain.DomainEntry{"user_id": "alice", "ip": "1.2.3.4"},
}, "did:key:z...", signer)

// 6. Verify the full chain at any time.
err = log.Verify()

Log rotation

Rotate seals the current log with a terminus entry and opens a new log with a genesis entry. The two entries share a fingerprint of the sealed log, allowing VerifyChain to confirm the rotation was not tampered with.

Caller responsibilities

Callers supply a did:key DID for every Append call, implement the Signer interface for their key material, and define EventType string constants. The library is domain-agnostic; structured payloads go through the Domain interface or the convenience type DomainEntry.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultRotatePath added in v0.3.0

func DefaultRotatePath(currentPath string) (string, error)

DefaultRotatePath returns the next log file path in the rotation sequence. Given "logs/audit-log.002.jsonl" it scans the directory for files matching "audit-log.*.jsonl" and returns the path with the next available three-digit sequence number, e.g. "logs/audit-log.003.jsonl".

If currentPath contains no sequence number the sequence starts at .001. Sequence numbers are zero-padded to three digits; beyond 999 the padding grows naturally to preserve lexicographic sort order.

This is the function used internally by Rotate. It is exported so callers can preview the next path (e.g. to pre-allocate storage) without triggering a rotation.

func MarshalForSign

func MarshalForSign(v any) ([]byte, error)

MarshalForSign serializes v to compact JSON without HTML escaping and without a trailing newline. This is the canonical form used for both signing and writing entries to disk — the two byte sequences are identical, so the raw on-disk line can be fed directly to the verifier without re-marshaling.

Exported to allow external tooling (re-verification scripts, log converters) to produce the same byte sequence that Append writes, without having to reimplement the encoding rules.

func VerifyChain added in v0.3.0

func VerifyChain(logDir string, baseName string) error

VerifyChain validates the rotation chain across all log files in logDir whose names match the pattern baseName.NNN.jsonl (e.g. "audit-log.000.jsonl"). At least two log files must exist.

For each consecutive pair (old → new), VerifyChain:

  1. Runs Verify on each log individually.
  2. Confirms the last entry of the old log is EventLogTerminus and the first entry of the new log is EventLogGenesis.
  3. Recomputes the SHA-256 of the old log file without the terminus line and checks it against the fingerprint stored in both terminus and genesis entries.
  4. Checks that Foundation.LogRef in the terminus points to the new log and Foundation.LogRef in the genesis points to the old log.

Returns nil if every log in the chain is internally valid and properly linked to its neighbors.

Types

type Domain

type Domain interface {
	Fields() map[string]any
}

Domain represents the structured payload attached to a log entry. Implement this interface to attach typed fields:

type LoginEvent struct {
    UserID string
    IP     string
}

func (e LoginEvent) Fields() map[string]any {
    return map[string]any{"user_id": e.UserID, "ip": e.IP}
}

The map returned by Fields is serialized as the "domain" JSON object. All keys and values must be JSON-serializable. The map is normalized before signing; key iteration order does not affect the output.

type DomainEntry

type DomainEntry map[string]any

DomainEntry is a map[string]any that satisfies the Domain interface. Use it for ad-hoc payloads without a dedicated struct:

sealchain.DomainEntry{"user_id": "alice", "ip": "1.2.3.4"}

For typed payloads, implement Domain on your own type instead. Values must be JSON-serializable; non-serializable values will cause Append to return an error.

func (DomainEntry) Fields

func (d DomainEntry) Fields() map[string]any

type Entry

type Entry struct {
	Foundation Foundation `json:"foundation"`
	Domain     Domain     `json:"domain,omitempty"`
	Event      EventType  `json:"event"`
}

Entry is a single record in the audit log. Callers populate Event and optionally Domain before passing an Entry to Append. All Foundation fields are computed and overwritten by Append; do not pre-set them.

func (Entry) MarshalJSON

func (e Entry) MarshalJSON() ([]byte, error)

MarshalJSON produces a canonicalized JSON representation of the entry. Domain fields are normalized to ensure byte-for-byte reproducibility across separate marshaling calls — a hard requirement for signature verification. Do not bypass this by marshaling Entry fields individually.

func (*Entry) UnmarshalJSON

func (e *Entry) UnmarshalJSON(data []byte) error

type EventType

type EventType string

EventType is the string tag that identifies what happened in a log entry. Callers define their own constants in their own packages:

const (
    EventUserCreated EventType = "user.created"
    EventOrderPlaced EventType = "order.placed"
)

Two values are reserved for log-rotation housekeeping and must not be used by callers: EventLogGenesis and EventLogTerminus.

const (
	EventLogTerminus EventType = "log.terminus"
	EventLogGenesis  EventType = "log.genesis"
)

EventLogTerminus and EventLogGenesis are written automatically by Rotate. EventLogTerminus is the last entry of a sealed log and carries a forward reference to the next log file. EventLogGenesis is the first entry of a newly rotated log and carries a back-reference to the previous log file. Callers must not use these values as their own event types.

type Foundation

type Foundation struct {
	// Seq is a 1-based monotonically increasing sequence number.
	// Gaps in Seq are detected and rejected by Verify.
	Seq uint64 `json:"seq"`

	// PrevHash is "sha256:<hex>" of the previous raw on-disk line, or the
	// literal string "genesis" for the first entry in a log.
	// IMPORTANT: compute this hash from raw disk bytes, never from a
	// re-marshaled Entry — the two will not match.
	PrevHash string `json:"prev_hash"`

	// ActorDID is the did:key DID of the signing actor.
	// Format: "did:key:z" + base58(uvarint(0xed) + ed25519PublicKeyBytes).
	// Verify resolves the public key from this field to check Signature.
	ActorDID string `json:"actor_did"`

	// Timestamp is the RFC3339 UTC time at which the entry was appended.
	// Set by Append; not authenticated beyond being covered by Signature.
	Timestamp string `json:"timestamp"`

	// Signature is the base64-encoded Ed25519 signature of the canonical
	// JSON of the entry with Signature set to "". Set by Append via Signer.
	Signature string `json:"signature"`

	// LogRole is "genesis" or "terminus". Only set on the first and last
	// entries of a rotated log segment; omitted from JSON in normal entries.
	LogRole string `json:"log_role,omitempty"`

	// LogRef is a file path cross-referencing the adjacent log segment.
	// Terminus entries point forward to the new log; genesis entries point
	// back to the previous log. Omitted from JSON in normal entries.
	LogRef string `json:"log_ref,omitempty"`
}

Foundation holds the cryptographic fields present in every log entry. All fields are set automatically by Append; callers must not pre-populate them (they will be overwritten). Together these fields form the tamper- evident chain: any modification to a committed entry breaks either the hash chain (PrevHash) or the signature (Signature) or both.

Hash chain invariant: PrevHash is the SHA-256 of the raw bytes of the previous line exactly as written to disk — not the re-marshaled parsed struct. The first entry always uses the literal string "genesis" instead of a hash, anchoring the chain against prepend attacks.

Signature invariant: the signature is computed over the full entry JSON with Signature set to "". Verification clears Signature, re-marshals the entry, and checks the Ed25519 signature against the result. The public key is derived from ActorDID, cryptographically binding actor identity to entry.

type Log

type Log struct {
	// contains filtered or unexported fields
}

Log is a file-backed append-only audit log. It is safe for concurrent use: Append holds an exclusive write lock and Entries/Verify hold a shared read lock.

A Log is a lightweight handle — multiple instances pointing to the same path are safe as long as all access goes through this type's methods. Direct modification of the underlying file bypasses locking and will corrupt the chain.

func NewLog

func NewLog(path string) *Log

NewLog returns a Log handle for path. The file is created on the first Append if it does not yet exist. Calling NewLog on an existing log file is safe — previous entries are preserved and subsequent Appends extend the chain.

func (*Log) Append

func (l *Log) Append(e Entry, did string, s Signer) error

Append signs and appends e to the log. Callers set e.Event and optionally e.Domain; all Foundation fields are computed and overwritten:

  • Seq is set to the next sequence number (existing entries + 1).
  • PrevHash is "sha256:<hex>" of the last raw on-disk line, or "genesis" for the first entry.
  • ActorDID is set to did.
  • Timestamp is set to the current UTC time in RFC3339.
  • Signature is the base64-encoded Ed25519 signature of the canonical JSON of the entry with Signature = "".

The entry is written as a single newline-terminated JSON line and fsynced before Append returns. On any error the log should be considered potentially corrupt and further appends must not be attempted.

did must be a valid did:key DID with an Ed25519 public key; this is the key Verify will use to check the signature later.

func (*Log) Entries

func (l *Log) Entries() ([]Entry, error)

Entries returns all entries in append order. Domain fields are decoded as DomainEntry (map[string]any). Returns nil, nil if the log file does not exist.

func (*Log) Fingerprint added in v0.3.0

func (l *Log) Fingerprint() (string, error)

Fingerprint returns "sha256:<hex>" of the entire log file's raw bytes. This hash is recorded in terminus and genesis entries during Rotate and verified by VerifyChain to confirm the sealed log was not modified after rotation. Reading an empty or non-existent log file returns an error.

func (*Log) IsGenesis added in v0.3.0

func (l *Log) IsGenesis() bool

IsGenesis reports whether this log was created by a rotation — that is, its first entry carries EventLogGenesis. The initial log in a chain will not have a genesis entry; every subsequent log will.

func (*Log) IsTerminus added in v0.3.0

func (l *Log) IsTerminus() bool

IsTerminus reports whether this log has been sealed by Rotate — that is, its last entry carries EventLogTerminus. A sealed log must not have further entries appended; use the Log returned by Rotate instead.

func (*Log) Path added in v0.3.0

func (l *Log) Path() string

Path returns the file system path this log was opened with.

func (*Log) Rotate added in v0.3.0

func (l *Log) Rotate(reason RotationReason, did string, s Signer) (*Log, error)

Rotate seals the current log and opens a new one, establishing a cryptographically verifiable link between them. It:

  1. Computes a fingerprint (SHA-256) of the current log file content.
  2. Writes a terminus entry to the current log containing the fingerprint, the rotation reason, and a forward reference (Foundation.LogRef) to the new log path.
  3. Creates the new log at the next sequence path (via DefaultRotatePath) and writes a genesis entry referencing the old log and its fingerprint.

The write lock is held only during terminus append, then released before creating the new log. After Rotate returns, the caller must switch to the returned Log for further appends; writing to the original Log after Rotate corrupts the chain.

VerifyChain validates the fingerprint linkage across the full rotation chain. reason is stored in the domain fields of both terminus and genesis entries.

func (*Log) Verify

func (l *Log) Verify() error

Verify validates the full cryptographic integrity of the log. For each entry:

  1. Seq must be contiguous — gaps or duplicates are rejected.
  2. PrevHash must equal "sha256:<hex>" of the previous raw on-disk line. The first entry must have PrevHash == "genesis".
  3. ActorDID must be a did:key DID with an Ed25519 public key.
  4. The Ed25519 signature must verify against the canonical JSON of the entry with Signature set to "".

Returns nil if the log is intact. On failure the error identifies the first bad entry by sequence number and describes the specific check that failed.

Verify checks only a single log file. To validate a rotation chain spanning multiple files, use VerifyChain.

type RotationReason added in v0.3.0

type RotationReason string

RotationReason records why a log was rotated. It is stored in the domain fields of the terminus and genesis entries so audit consumers can distinguish scheduled rotation from operator-initiated rotation.

const (
	// RotationSize indicates the log was rotated because it exceeded a size limit.
	RotationSize RotationReason = "size_threshold"
	// RotationTime indicates the log was rotated on a time schedule.
	RotationTime RotationReason = "time_threshold"
	// RotationManual indicates the log was rotated by an operator.
	RotationManual RotationReason = "manual"
)

type Signer

type Signer interface {
	Sign(message []byte) ([]byte, error)
}

Signer signs arbitrary message bytes and returns the raw signature. Implement this interface to supply Ed25519 key material to Append and Rotate:

type Ed25519Signer struct{ Key ed25519.PrivateKey }

func (s Ed25519Signer) Sign(msg []byte) ([]byte, error) {
    return ed25519.Sign(s.Key, msg), nil
}

The message passed to Sign is the canonical JSON of the entry with Foundation.Signature set to "". The returned bytes are base64-encoded and stored as Foundation.Signature. Only Ed25519 keys encoded in a did:key DID are supported; other key types are rejected by Verify.

Directories

Path Synopsis
cmd
sealcheck command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL