Archived
303 lines
12 KiB
C
303 lines
12 KiB
C
/* engram at-rest encryption — prototype sketch
|
|
*
|
|
* NOT WIRED UP. This file is intentionally not in the build. It is a sketch
|
|
* that pairs with `at-rest-encryption.md` so the function signatures and the
|
|
* field layout can be reviewed before the runtime PQ primitives stabilize.
|
|
*
|
|
* Once `el_runtime.c` has stable `el_sha3_256_*`, `el_hkdf_sha3_256`,
|
|
* `pq_kem_encaps`, `pq_kem_decaps`, and `el_aes256_gcm_encrypt/decrypt`,
|
|
* the seal/open path below should be lifted into el_runtime.c near the
|
|
* existing `engram_save` / `engram_load` block (~line 3445), gated behind
|
|
* the ENGRAM_SEAL_MODE env var.
|
|
*
|
|
* Dependencies (provided elsewhere — see at-rest-encryption.md §8):
|
|
* void el_hkdf_sha3_256(const uint8_t* ikm, size_t ikm_len,
|
|
* const uint8_t* salt, size_t salt_len,
|
|
* const uint8_t* info, size_t info_len,
|
|
* uint8_t* okm, size_t okm_len);
|
|
*
|
|
* int el_aes256_gcm_encrypt(const uint8_t key[32],
|
|
* const uint8_t nonce[12],
|
|
* const uint8_t* aad, size_t aad_len,
|
|
* const uint8_t* pt, size_t pt_len,
|
|
* uint8_t* ct, // pt_len bytes
|
|
* uint8_t tag[16]);
|
|
*
|
|
* int el_aes256_gcm_decrypt(const uint8_t key[32],
|
|
* const uint8_t nonce[12],
|
|
* const uint8_t* aad, size_t aad_len,
|
|
* const uint8_t* ct, size_t ct_len,
|
|
* const uint8_t tag[16],
|
|
* uint8_t* pt); // ct_len bytes
|
|
*
|
|
* int pq_kem_encaps(const uint8_t* pk, size_t pk_len,
|
|
* uint8_t* kem_ct, size_t* kem_ct_len,
|
|
* uint8_t shared[32]);
|
|
* int pq_kem_decaps(const uint8_t* sk, size_t sk_len,
|
|
* const uint8_t* kem_ct, size_t kem_ct_len,
|
|
* uint8_t shared[32]);
|
|
*
|
|
* void el_random_bytes(uint8_t* out, size_t n);
|
|
* void el_secure_zero(void* p, size_t n);
|
|
*/
|
|
|
|
#include <stdint.h>
|
|
#include <stddef.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdio.h>
|
|
|
|
/* ── module state (mlocked) ───────────────────────────────────────────────── */
|
|
|
|
static uint8_t g_engram_dek[32];
|
|
static int g_engram_dek_set = 0;
|
|
static uint32_t g_engram_dek_epoch = 0;
|
|
|
|
/* Seal/unseal mode — read once at boot from ENGRAM_SEAL_MODE.
|
|
* 0=off, 1=fields, 2=full. */
|
|
static int g_engram_seal_mode = 0;
|
|
|
|
/* ── per-record seal ──────────────────────────────────────────────────────── */
|
|
|
|
/* Wire format (before base64):
|
|
* byte 0 version=0x01
|
|
* bytes 1..4 epoch (big-endian u32)
|
|
* bytes 5..16 nonce (12B)
|
|
* bytes 17.. ciphertext (pt_len bytes)
|
|
* last 16B GCM tag
|
|
*/
|
|
static char* eg_b64_encode(const uint8_t* data, size_t n); /* declared elsewhere */
|
|
|
|
char* engram_aead_seal_field(const char* record_id, const char* plaintext)
|
|
{
|
|
if (!g_engram_dek_set) return NULL;
|
|
if (!record_id || !plaintext) return NULL;
|
|
|
|
size_t pt_len = strlen(plaintext);
|
|
uint32_t epoch = g_engram_dek_epoch;
|
|
|
|
/* derive sub-key */
|
|
uint8_t sub_key[32];
|
|
uint8_t info[96];
|
|
int info_len = snprintf((char*)info, sizeof(info), "%s:%u", record_id, epoch);
|
|
if (info_len < 0 || (size_t)info_len >= sizeof(info)) return NULL;
|
|
|
|
el_hkdf_sha3_256(g_engram_dek, 32,
|
|
(const uint8_t*)"engram-record-v1", 16,
|
|
info, (size_t)info_len,
|
|
sub_key, 32);
|
|
|
|
/* random nonce */
|
|
uint8_t nonce[12];
|
|
el_random_bytes(nonce, 12);
|
|
|
|
/* allocate output: header(17) + ct(pt_len) + tag(16) */
|
|
size_t blob_len = 1 + 4 + 12 + pt_len + 16;
|
|
uint8_t* blob = (uint8_t*)malloc(blob_len);
|
|
if (!blob) { el_secure_zero(sub_key, 32); return NULL; }
|
|
|
|
blob[0] = 0x01;
|
|
blob[1] = (uint8_t)((epoch >> 24) & 0xff);
|
|
blob[2] = (uint8_t)((epoch >> 16) & 0xff);
|
|
blob[3] = (uint8_t)((epoch >> 8) & 0xff);
|
|
blob[4] = (uint8_t)( epoch & 0xff);
|
|
memcpy(blob + 5, nonce, 12);
|
|
|
|
int rc = el_aes256_gcm_encrypt(
|
|
sub_key, nonce,
|
|
(const uint8_t*)record_id, strlen(record_id),
|
|
(const uint8_t*)plaintext, pt_len,
|
|
blob + 1 + 4 + 12, /* ct */
|
|
blob + 1 + 4 + 12 + pt_len); /* tag */
|
|
|
|
el_secure_zero(sub_key, 32);
|
|
|
|
if (rc != 0) { free(blob); return NULL; }
|
|
|
|
char* b64 = eg_b64_encode(blob, blob_len);
|
|
free(blob);
|
|
return b64; /* caller prefixes "v1:<epoch>:" if desired */
|
|
}
|
|
|
|
/* Returns malloc'd plaintext (NUL-terminated) or NULL on AEAD failure / version
|
|
* mismatch / DEK-epoch mismatch. */
|
|
char* engram_aead_open_field(const char* record_id, const char* b64_blob)
|
|
{
|
|
if (!g_engram_dek_set || !record_id || !b64_blob) return NULL;
|
|
|
|
size_t blob_len = 0;
|
|
uint8_t* blob = NULL;
|
|
extern uint8_t* eg_b64_decode(const char* s, size_t* out_len);
|
|
blob = eg_b64_decode(b64_blob, &blob_len);
|
|
if (!blob || blob_len < 1 + 4 + 12 + 16) { free(blob); return NULL; }
|
|
|
|
if (blob[0] != 0x01) { free(blob); return NULL; }
|
|
uint32_t epoch = ((uint32_t)blob[1] << 24)
|
|
| ((uint32_t)blob[2] << 16)
|
|
| ((uint32_t)blob[3] << 8)
|
|
| (uint32_t)blob[4];
|
|
|
|
/* For the common case the on-disk epoch matches g_engram_dek_epoch.
|
|
* During DEK rotation we may need a previous-epoch DEK in the keyring;
|
|
* left as a TODO — this stub only handles the current epoch. */
|
|
if (epoch != g_engram_dek_epoch) {
|
|
free(blob);
|
|
return NULL;
|
|
}
|
|
|
|
const uint8_t* nonce = blob + 5;
|
|
size_t ct_len = blob_len - (1 + 4 + 12 + 16);
|
|
const uint8_t* ct = blob + 1 + 4 + 12;
|
|
const uint8_t* tag = blob + 1 + 4 + 12 + ct_len;
|
|
|
|
uint8_t sub_key[32];
|
|
uint8_t info[96];
|
|
int info_len = snprintf((char*)info, sizeof(info), "%s:%u", record_id, epoch);
|
|
if (info_len < 0 || (size_t)info_len >= sizeof(info)) { free(blob); return NULL; }
|
|
el_hkdf_sha3_256(g_engram_dek, 32,
|
|
(const uint8_t*)"engram-record-v1", 16,
|
|
info, (size_t)info_len,
|
|
sub_key, 32);
|
|
|
|
char* pt = (char*)malloc(ct_len + 1);
|
|
if (!pt) { el_secure_zero(sub_key, 32); free(blob); return NULL; }
|
|
|
|
int rc = el_aes256_gcm_decrypt(
|
|
sub_key, nonce,
|
|
(const uint8_t*)record_id, strlen(record_id),
|
|
ct, ct_len, tag,
|
|
(uint8_t*)pt);
|
|
|
|
el_secure_zero(sub_key, 32);
|
|
free(blob);
|
|
|
|
if (rc != 0) { free(pt); return NULL; }
|
|
pt[ct_len] = '\0';
|
|
return pt;
|
|
}
|
|
|
|
/* ── KEK boot ─────────────────────────────────────────────────────────────── */
|
|
|
|
/* Reads engram.kek.enc, runs Kyber decaps with the principal SK provided as
|
|
* a buffer, derives the wrap key, AEAD-opens the sealed DEK, and installs it
|
|
* into g_engram_dek. Returns 0 on success, non-zero on failure. */
|
|
int engram_kek_unwrap(const char* kek_path,
|
|
const uint8_t* principal_sk, size_t sk_len)
|
|
{
|
|
/* TODO: file format
|
|
* magic "ENGKEK01" (8B)
|
|
* version (1B)
|
|
* principal_id_len (2B BE) | principal_id
|
|
* kem_ct_len (4B BE) | kem_ct
|
|
* nonce (12B)
|
|
* sealed_dek_len (4B BE) | sealed_dek
|
|
* tag (16B)
|
|
*/
|
|
(void)kek_path; (void)principal_sk; (void)sk_len;
|
|
return -1; /* TODO: parse file, pq_kem_decaps, HKDF, AEAD-open, install */
|
|
}
|
|
|
|
/* ── KEK init (first-time write) ──────────────────────────────────────────── */
|
|
|
|
int engram_kek_init(const char* kek_path,
|
|
const uint8_t* principal_pk, size_t pk_len)
|
|
{
|
|
/* TODO:
|
|
* 1. el_random_bytes(g_engram_dek, 32); g_engram_dek_epoch = 1;
|
|
* 2. pq_kem_encaps(principal_pk) → (kem_ct, shared)
|
|
* 3. wrap_key = HKDF-SHA3-256(shared, salt="engram-kek-v1",
|
|
* info="dek-wrap", L=32)
|
|
* 4. AEAD-seal DEK under wrap_key
|
|
* 5. write file atomically (tmp + rename + fsync)
|
|
* 6. el_secure_zero(shared, wrap_key)
|
|
*/
|
|
(void)kek_path; (void)principal_pk; (void)pk_len;
|
|
return -1;
|
|
}
|
|
|
|
/* ── DEK rotation ─────────────────────────────────────────────────────────── */
|
|
|
|
int engram_dek_rotate(void)
|
|
{
|
|
/* TODO:
|
|
* - Allocate DEK_{n+1}.
|
|
* - Walk EngramStore: for every node and edge, re-seal each encrypted
|
|
* field under DEK_{n+1} with the new epoch.
|
|
* - Re-wrap DEK_{n+1} under current Principal pk → engram.kek.enc.new
|
|
* - Write a new snapshot via engram_save() (caller responsibility).
|
|
* - Atomic rename engram.kek.enc.new → engram.kek.enc.
|
|
* - el_secure_zero on DEK_n; bump g_engram_dek_epoch.
|
|
*/
|
|
return -1;
|
|
}
|
|
|
|
/* ── Recovery (Shamir K-of-N) ─────────────────────────────────────────────── */
|
|
|
|
/* The recovery secret R is independent of the DEK. R is split via Shamir;
|
|
* each share is then PQ-wrapped to its shareholder via Kyber768. R is the
|
|
* AEAD wrap-key for an envelope sealing the DEK.
|
|
*
|
|
* Two on-disk artifacts:
|
|
* engram.recovery.shares — public; one Kyber-wrapped Shamir share / shareholder
|
|
* engram.recovery.envelope — AEAD(R, nonce, plaintext=DEK, ad="engram-recovery-v1")
|
|
*/
|
|
|
|
int engram_recovery_split(int threshold, int total,
|
|
const uint8_t** shareholder_pks,
|
|
const size_t* shareholder_pk_lens,
|
|
const char** shareholder_ids,
|
|
const char* shares_out_path,
|
|
const char* envelope_out_path)
|
|
{
|
|
/* TODO:
|
|
* 1. el_random_bytes(R, 32).
|
|
* 2. Shamir-split R over GF(2^8) → total shares of {1B index, 32B y_i}.
|
|
* 3. For each shareholder i: pq_kem_encaps(pk_i) → (kem_ct, shared);
|
|
* AEAD-seal share_i under HKDF(shared) → wrapped_share_i; write entry.
|
|
* 4. AEAD-seal current DEK under R → envelope.
|
|
* 5. Atomic-write both files; fsync.
|
|
* 6. Zero R, all shared secrets.
|
|
*/
|
|
(void)threshold; (void)total;
|
|
(void)shareholder_pks; (void)shareholder_pk_lens; (void)shareholder_ids;
|
|
(void)shares_out_path; (void)envelope_out_path;
|
|
return -1;
|
|
}
|
|
|
|
int engram_recovery_reconstitute(const uint8_t* k_unwrapped_shares,
|
|
size_t share_count,
|
|
const char* envelope_path,
|
|
uint8_t out_dek[32])
|
|
{
|
|
/* TODO:
|
|
* 1. Lagrange-interpolate to recover R from the K shares.
|
|
* 2. AEAD-open the envelope under HKDF(R) → DEK.
|
|
* 3. Zero R.
|
|
*/
|
|
(void)k_unwrapped_shares; (void)share_count;
|
|
(void)envelope_path; (void)out_dek;
|
|
return -1;
|
|
}
|
|
|
|
/* ── Snapshot integration sketch ──────────────────────────────────────────── */
|
|
|
|
/*
|
|
* engram_emit_node_json (around line 3409 in el_runtime.c) becomes:
|
|
*
|
|
* if (g_engram_seal_mode == 1) {
|
|
* char* sealed = engram_aead_seal_field(n->id, n->content);
|
|
* jb_puts(b, ",\"content\":"); jb_emit_escaped(b, sealed ? sealed : "");
|
|
* free(sealed);
|
|
* // same for label, tags, metadata
|
|
* } else {
|
|
* jb_puts(b, ",\"content\":"); jb_emit_escaped(b, n->content);
|
|
* }
|
|
*
|
|
* engram_load (around line 3499) becomes the inverse: when reading each field,
|
|
* if the daemon is in `fields` mode and the value is non-empty, run
|
|
* engram_aead_open_field; on failure log and substitute "<corrupted>".
|
|
*
|
|
* `engram_save` must additionally write engram.kek.enc the first time (via
|
|
* engram_kek_init) when ENGRAM_SEAL_MODE != off and no kek file exists.
|
|
*/
|