259 lines
8.9 KiB
Nim
259 lines
8.9 KiB
Nim
# SPDX-License-Identifier: LSL-1.0
|
|
# Copyright (c) 2026 Markus Maiwald
|
|
# Stewardship: Self Sovereign Society Foundation
|
|
#
|
|
# This file is part of the Nexus Sovereign Core.
|
|
# See legal/LICENSE_SOVEREIGN.md for license terms.
|
|
|
|
# Read-Only Protection Manager
|
|
#
|
|
# This module implements the read-only protection system for CAS storage,
|
|
# ensuring immutability by default with controlled write access elevation.
|
|
#
|
|
# SECURITY NOTE: chmod-based protection is a UX feature, NOT a security feature!
|
|
# In user-mode (~/.local/share/nexus/cas/), chmod 555 only prevents ACCIDENTAL
|
|
# deletion/modification. A user who owns the files can bypass this trivially.
|
|
#
|
|
# Real security comes from:
|
|
# 1. Merkle tree verification (cryptographic integrity)
|
|
# 2. User namespaces (kernel-enforced read-only mounts during execution)
|
|
# 3. Root ownership (system-mode only: /var/lib/nexus/cas/)
|
|
#
|
|
# See docs/cas-security-architecture.md for full security model.
|
|
|
|
import std/[os, times, sequtils, strutils]
|
|
import xxhash
|
|
import ./types
|
|
|
|
type
|
|
ProtectionManager* = object
|
|
casPath*: string # Path to CAS root directory
|
|
auditLog*: string # Path to audit log file
|
|
|
|
SecurityEvent* = object
|
|
timestamp*: DateTime
|
|
eventType*: string
|
|
hash*: string
|
|
details*: string
|
|
severity*: string # "info", "warning", "critical"
|
|
|
|
|
|
proc newProtectionManager*(casPath: string): ProtectionManager =
|
|
## Create a new protection manager for the given CAS path
|
|
result = ProtectionManager(
|
|
casPath: casPath,
|
|
auditLog: casPath / "audit.log"
|
|
)
|
|
|
|
proc logOperation*(pm: ProtectionManager, op: string, path: string, hash: string = "") =
|
|
## Log a write operation to the audit log
|
|
try:
|
|
let timestamp = now().format("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
|
var logEntry = "[" & timestamp & "] " & op & " path=" & path
|
|
if hash.len > 0:
|
|
logEntry.add(" hash=" & hash)
|
|
logEntry.add("\n")
|
|
|
|
let logFile = open(pm.auditLog, fmAppend)
|
|
logFile.write(logEntry)
|
|
logFile.close()
|
|
except IOError:
|
|
# If we can't write to audit log, continue anyway
|
|
# (better to allow operation than to fail)
|
|
discard
|
|
|
|
proc setReadOnly*(pm: ProtectionManager): VoidResult[NimPakError] =
|
|
## Set CAS directory to read-only (chmod 555)
|
|
try:
|
|
setFilePermissions(pm.casPath, {fpUserRead, fpUserExec,
|
|
fpGroupRead, fpGroupExec,
|
|
fpOthersRead, fpOthersExec})
|
|
pm.logOperation("SET_READONLY", pm.casPath)
|
|
return ok(NimPakError)
|
|
except OSError as e:
|
|
return VoidResult[NimPakError](isOk: false, errValue: NimPakError(
|
|
code: FileWriteError,
|
|
msg: "Failed to set read-only permissions: " & e.msg
|
|
))
|
|
|
|
proc setWritable*(pm: ProtectionManager): VoidResult[NimPakError] =
|
|
## Set CAS directory to writable (chmod 755)
|
|
try:
|
|
setFilePermissions(pm.casPath, {fpUserRead, fpUserWrite, fpUserExec,
|
|
fpGroupRead, fpGroupExec,
|
|
fpOthersRead, fpOthersExec})
|
|
pm.logOperation("SET_WRITABLE", pm.casPath)
|
|
return ok(NimPakError)
|
|
except OSError as e:
|
|
return VoidResult[NimPakError](isOk: false, errValue: NimPakError(
|
|
code: FileWriteError,
|
|
msg: "Failed to set writable permissions: " & e.msg
|
|
))
|
|
|
|
proc withWriteAccess*(pm: ProtectionManager, operation: proc()): VoidResult[NimPakError] =
|
|
## Execute operation with temporary write access, then restore read-only
|
|
## This ensures atomic permission elevation and restoration
|
|
var oldPerms: set[FilePermission]
|
|
|
|
try:
|
|
# Save current permissions
|
|
oldPerms = getFilePermissions(pm.casPath)
|
|
|
|
# Enable write (755)
|
|
let setWritableResult = pm.setWritable()
|
|
if not setWritableResult.isOk:
|
|
return setWritableResult
|
|
|
|
# Perform operation
|
|
operation()
|
|
|
|
# Restore read-only (555)
|
|
let setReadOnlyResult = pm.setReadOnly()
|
|
if not setReadOnlyResult.isOk:
|
|
return setReadOnlyResult
|
|
|
|
return ok(NimPakError)
|
|
|
|
except Exception as e:
|
|
# Ensure permissions restored even on error
|
|
try:
|
|
setFilePermissions(pm.casPath, oldPerms)
|
|
pm.logOperation("RESTORE_PERMS_AFTER_ERROR", pm.casPath)
|
|
except:
|
|
discard # Best effort to restore
|
|
|
|
return VoidResult[NimPakError](isOk: false, errValue: NimPakError(
|
|
code: UnknownError,
|
|
msg: "Write operation failed: " & e.msg
|
|
))
|
|
|
|
proc ensureReadOnly*(pm: ProtectionManager): VoidResult[NimPakError] =
|
|
## Ensure CAS directory is in read-only state
|
|
## This should be called during initialization
|
|
return pm.setReadOnly()
|
|
|
|
proc verifyReadOnly*(pm: ProtectionManager): bool =
|
|
## Verify that CAS directory is in read-only state
|
|
try:
|
|
let perms = getFilePermissions(pm.casPath)
|
|
# Check that write permission is not set for user
|
|
return fpUserWrite notin perms
|
|
except:
|
|
return false
|
|
|
|
|
|
# Merkle Integrity Verification
|
|
# This is the PRIMARY security mechanism (not chmod)
|
|
|
|
|
|
|
|
proc logSecurityEvent*(pm: ProtectionManager, event: SecurityEvent) =
|
|
## Log security events (integrity violations, tampering attempts, etc.)
|
|
try:
|
|
let timestamp = event.timestamp.format("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
|
let logEntry = "[" & timestamp & "] SECURITY_EVENT type=" & event.eventType &
|
|
" severity=" & event.severity & " hash=" & event.hash &
|
|
" details=" & event.details & "\n"
|
|
|
|
let logFile = open(pm.auditLog, fmAppend)
|
|
logFile.write(logEntry)
|
|
logFile.close()
|
|
except IOError:
|
|
# If we can't write to audit log, at least try stderr
|
|
stderr.writeLine("SECURITY EVENT: " & event.eventType & " - " & event.details)
|
|
|
|
proc verifyChunkIntegrity*(pm: ProtectionManager, data: seq[byte], expectedHash: string): VoidResult[NimPakError] =
|
|
## Verify chunk integrity by recalculating hash
|
|
## This is the PRIMARY security mechanism - always verify before use
|
|
try:
|
|
let calculatedHash = "xxh3-" & $XXH3_128bits(cast[string](data))
|
|
|
|
if calculatedHash != expectedHash:
|
|
# CRITICAL: Hash mismatch detected!
|
|
let event = SecurityEvent(
|
|
timestamp: now(),
|
|
eventType: "INTEGRITY_VIOLATION",
|
|
hash: expectedHash,
|
|
details: "Hash mismatch: expected=" & expectedHash & " calculated=" & calculatedHash,
|
|
severity: "critical"
|
|
)
|
|
pm.logSecurityEvent(event)
|
|
|
|
return VoidResult[NimPakError](isOk: false, errValue: NimPakError(
|
|
code: UnknownError,
|
|
context: "Object Hash: " & expectedHash,
|
|
msg: "Chunk integrity violation detected! Expected: " & expectedHash &
|
|
", Got: " & calculatedHash & ". This chunk may be corrupted or tampered with."
|
|
))
|
|
|
|
# Hash matches - integrity verified
|
|
let event = SecurityEvent(
|
|
timestamp: now(),
|
|
eventType: "INTEGRITY_VERIFIED",
|
|
hash: expectedHash,
|
|
details: "Chunk integrity verified successfully",
|
|
severity: "info"
|
|
)
|
|
pm.logSecurityEvent(event)
|
|
|
|
return ok(NimPakError)
|
|
|
|
except Exception as e:
|
|
return VoidResult[NimPakError](isOk: false, errValue: NimPakError(
|
|
code: UnknownError,
|
|
msg: "Failed to verify chunk integrity: " & e.msg,
|
|
context: "Object Hash: " & expectedHash
|
|
))
|
|
|
|
proc verifyChunkIntegrityFromFile*(pm: ProtectionManager, filePath: string, expectedHash: string): VoidResult[NimPakError] =
|
|
## Verify chunk integrity by reading file and checking hash
|
|
try:
|
|
let data = readFile(filePath)
|
|
let byteData = data.toOpenArrayByte(0, data.len - 1).toSeq()
|
|
return pm.verifyChunkIntegrity(byteData, expectedHash)
|
|
except IOError as e:
|
|
return VoidResult[NimPakError](isOk: false, errValue: NimPakError(
|
|
code: FileReadError,
|
|
msg: "Failed to read chunk file for verification: " & e.msg,
|
|
context: "Object Hash: " & expectedHash
|
|
))
|
|
|
|
proc scanCASIntegrity*(pm: ProtectionManager, casPath: string): tuple[verified: int, corrupted: seq[string]] =
|
|
## Scan entire CAS directory and verify integrity of all chunks
|
|
## Returns count of verified chunks and list of corrupted chunk hashes
|
|
result.verified = 0
|
|
result.corrupted = @[]
|
|
|
|
try:
|
|
let chunksDir = casPath / "chunks"
|
|
if not dirExists(chunksDir):
|
|
return
|
|
|
|
for entry in walkDirRec(chunksDir):
|
|
if fileExists(entry):
|
|
# Extract hash from filename
|
|
let filename = extractFilename(entry)
|
|
# Assume format: xxh3-<hash>.zst or just <hash>
|
|
var hash = filename
|
|
if not hash.startsWith("xxh3-"):
|
|
hash = "xxh3-" & hash.replace(".zst", "")
|
|
|
|
# Verify integrity
|
|
let verifyResult = pm.verifyChunkIntegrityFromFile(entry, hash)
|
|
if verifyResult.isOk:
|
|
result.verified.inc
|
|
else:
|
|
result.corrupted.add(hash)
|
|
|
|
# Log corruption
|
|
let event = SecurityEvent(
|
|
timestamp: now(),
|
|
eventType: "CORRUPTION_DETECTED",
|
|
hash: hash,
|
|
details: "Chunk failed integrity check during scan",
|
|
severity: "critical"
|
|
)
|
|
pm.logSecurityEvent(event)
|
|
except Exception as e:
|
|
stderr.writeLine("Error during CAS integrity scan: " & e.msg)
|