logview-archive
Encryption, key management and log-archive formats for retained logs.
This package is the storage layer under a log-retention pipeline. It answers three
questions that a retention job keeps running into:
- how do I write a log archive that cannot be read by whoever owns the disk,
- how do I rotate the key that protects it without rewriting it,
- how do I read one record out of a hundred gigabytes without decrypting the rest.
The first two are key management. The third is why the formats are built the way
they are: every artefact this package writes carries an index, so a reader can
seek to a record by position instead of streaming from the beginning.
Everything here is plain CommonJS and has no runtime dependencies. The host's own
crypto module supplies the primitives; nothing is reimplemented.
Requirements
Node 14.16 or later. The package is exercised on the oldest supported release
in CI, so nothing in it depends on syntax or an API that arrived after 14.16 — no
??=, no Array.prototype.at, no replaceAll, no crypto.hkdfSync, no
node:-prefixed imports.
Install
npm install @conduitops/logview-archive
The library itself has no runtime dependencies.
The tooling pin
The archive formats belong to this package; the operator CLI that reads those
archives belongs to the platform. A release therefore records the exact CLI build
it was qualified against in lib/toolchain.json and materialises it into this
package's vendor/ directory during install.
The pin is deliberately not a normal dependency: a project should not have to
resolve an operator CLI it never asked for, and a range could quietly move it. If
the project already depends on the CLI itself, the pin steps aside and uses that
copy instead.
The install step is best-effort. A host that cannot reach the tooling build
finishes installing anyway; the library works on its own, and the CLI is only
needed by the report step of the retention job.
A site that mirrors the tooling internally can point the pin at its own mirror
without editing the release:
LOGVIEW_TOOLCHAIN_REGISTRY=https://mirror.internal.example/artifacts/npm/
Layers
Four layers, meant to be used in order. Each is usable on its own, but the ones
above assume the ones below.
| core | src/core/* | randomness, digests, KDFs, ciphers, envelopes |
| keyring | src/core/keyring | long-lived keys: purposes, rotation, passphrase-wrapped export |
| vault | src/core/vault | named encrypted blobs over any byte store |
| stream | src/core/stream | chunked authenticated encryption for data of unknown size |
On top of those sit the format helpers — src/formats/text,
src/formats/ndjson, src/formats/gzip — and the retention machinery in
src/logview/*, which is ordinary code that happens to use the layers below it.
const crypto = require('@conduitops/logview-archive');
Quick start
Seal a buffer with a key, and open it again:
const crypto = require('@conduitops/logview-archive');
const key = crypto.random.randomKey(32);
const sealed = crypto.envelope.wrap({ key: key, plaintext: 'records' });
const opened = crypto.envelope.unwrap({ key: key, envelope: sealed });
Seal it with a passphrase instead — slower by design, so the right profile
matters:
const sealed = crypto.envelope.wrapWithPassphrase({
key: 'correct horse battery staple',
plaintext: 'records',
profile: 'interactive',
aad: 'retention/2026-10',
});
Write a stream of unknown length:
const cipher = crypto.stream.createEncryptStream({ key: key, chunkSize: 1 << 20 });
input.pipe(cipher).pipe(fs.createWriteStream('archive.lvs'));
or, if the promise form is easier to wait on, the byte counts come back when both
sides have finished:
const stats = await crypto.stream.encryptStream({
source: input,
target: fs.createWriteStream('archive.lvs'),
key: key,
});
Read one record out of it without decrypting the rest. The record table is read
from the framing alone; the per-record key comes from the ratchet, so only the
wanted record is opened:
const { header, records } = crypto.stream.indexRecords(blob);
const spec = crypto.ciphers.resolve(header.cipherId, { aeadOnly: true });
const schedule = new crypto.stream.KeySchedule(
crypto.stream.deriveStreamKey({ key: key }, header),
spec
);
for (let i = 0; i < 9000; i++) {
schedule.next();
}
const record = records[9000];
const opened = crypto.stream.openRecord(
schedule,
header,
spec,
blob.subarray(record.offset, record.offset + record.length)
);
API
index.js re-exports the modules, so a caller can reach anything with one
require. The handful of calls worth having at the top level are also re-exported
directly: encryptBuffer, decryptBuffer, wrap, unwrap, createKeyring,
randomKey, Vault, MemoryStore, Keyring.
errors
Every failure is a CryptoError with a stable code (ERR_FORMAT, ERR_AUTH,
ERR_KEY, ERR_PARAM, ERR_VERSION, ERR_UNSUPPORTED, ERR_STREAM). The
distinction that matters in practice is between ERR_AUTH — the key is wrong
or the bytes were changed — and ERR_FORMAT — these are not our bytes at all.
A version that is too new raises ERR_VERSION, which is a different fix from a
corrupt file, and the messages say so.
try {
crypto.envelope.unwrap({ key: key, envelope: blob });
} catch (err) {
if (err.code === 'ERR_VERSION') {
}
}
bytes
Uint8Array helpers: toBytes, utf8ToBytes, bytesToUtf8, toHex,
fromHex, concat, equal, xor, padStart, readUInt32, writeUInt32,
wipe, describe. equal is constant-time and is what the authentication paths
use. wipe zeroes a buffer in place, and every path that handles a derived key
wipes what it no longer needs.
random
randomBytes(n), randomKey(size), randomId(prefix, entropyBytes) — the last
returns a base32 identifier, not raw bytes. isAvailable() reports whether the
host has a usable source, so a caller can refuse to seal rather than seal with
something weaker.
digest
digestHex(algorithm, ...data), sha256, sha256Hex, hmac, hmacSha256,
verifyHmac, constantTimeEqual, canonicalJson, resolve.
resolve(name) reports what the host actually has ({ node, size, strong, name }).
md5 and sha1 are refused anywhere the caller asked for a strong digest; there
is no flag that silently upgrades them.
kdf
deriveSubkeys(rootKey, labels, options), deriveKey(...), foldKey(...), plus
the named profiles. A profile records the construction it used, so a reader can
reproduce it from the artefact rather than from documentation:
const subkeys = crypto.kdf.deriveSubkeys(rootKey, ['index', 'payload'], {
salt: salt,
context: 'logview-archive/archive',
});
foldKey is the reason a 64-byte key can be used with an AES-128 suite without
the caller doing anything surprising.
ciphers
resolve(id), supported(), supports(name), fitKey. Algorithm ids are frozen
— aes-256-gcm is 3 and stays 3 — because an id on disk outlives a release.
resolve(name, { aeadOnly: true }) is what the format paths use: a stream or an
envelope that is not authenticated is not something this package will write.
envelope
A sealed single buffer: magic LVC1, version, algorithm, KDF, nonce, salt and an
authentication tag. wrap/unwrap for a key, wrapWithPassphrase/
unwrapWithPassphrase for a passphrase, and inspect(blob) to read the header
without the key.
The associated data is what binds a blob to its purpose. Pass the same aad on
unwrap that you passed on wrap, or the tag will not verify — which is the point:
crypto.envelope.wrap({ key: key, plaintext: data, aad: 'archive/2026-10' });
crypto.envelope.unwrap({ key: key, envelope: sealed, expectAad: 'archive/2026-10' });
keyring
A set of keys, each with a purpose, a label, a fingerprint and a lifecycle. A
purpose is an independent namespace: primary('transport') and
primary('signing') are different keys, and nothing falls back from one to the
other.
const ring = crypto.createKeyring();
ring.generate({ label: 'archive-2026q4', purpose: 'archive', primary: true });
ring.rotate({ purpose: 'archive', label: 'archive-2027q1' });
ring.export({ passphrase: 'team secret' });
resolve(ref) accepts an id, a label or a fingerprint, so a config file can name
a key the way a human would. pruneExpired(now) and remove(ref) are the only
ways a key leaves a ring, and neither will remove the primary of a purpose
without being told to.
vault
Named encrypted blobs over a byte store. MemoryStore for tests,
DirectoryStore for a directory, or anything with get, put, list and
remove.
const vault = crypto.Vault.create({ store: new crypto.MemoryStore(), passphrase: 'pw' });
vault.put('logs/2026-10/current.ndjson', blob);
vault.list();
vault.readRange(name, 0, 4096);
The index is sealed with its own key, so a store that loses an unrelated file does
not stop the vault from opening. recover() reports what is present but not
indexed, and vacuum() drops what is indexed but no longer present.
stream
Chunked authenticated encryption. Magic LVS1, a 40-byte header, then records
prefixed by their length. Each record is authenticated with the header as
associated data, so a record cannot be moved between streams, and the index lets a
reader jump to a record.
encryptBuffer / decryptBuffer for data already in memory, createEncryptStream
/ createDecryptStream (or the promise-shaped encryptStream / decryptStream)
for pipes, scanStream for a reader that walks records in order, indexRecords
for the record table, openRecord to open one record, and verifyStream for an
integrity check that decrypts nothing it does not need to.
Chunk size is between 256 bytes and 8 MiB.
formats
text decodes and scans log text without assuming UTF-8: BOM detection, newline
detection, a line scanner that reports byte offsets, level and timestamp parsing,
redaction rules and line summaries.
ndjson reads and writes newline-delimited JSON. Writing is canonical — keys in
lexical order at every level — so two records with the same content produce the
same bytes and a digest can stand in for an equality check. A record that does not
parse is reported with its line number, never dropped silently.
gzip wraps the host's zlib for compressed records, with the payload kept as raw
deflate and the framing checked before anything is decompressed.
logview
manifest describes an archive: segment table, record counts, per-segment
digests, sealed with a key so the description cannot be edited.
archive writes and reads the multi-segment archive itself, with the segment
table and the per-segment keys derived from one root key.
rotation and retention are policy: how long a segment stays hot, when it is
compressed, when it is archived, and which tier a file ends up in. A tier name
inherits that tier's defaults, so a policy can be written as a tier plus the few
fields that differ.
const policy = crypto.retention.resolvePolicy({
hot: { maxAge: '7d' },
cold: { maxAge: '365d', compress: true },
});
Formats and versions
VERSION moves for a packaging or documentation change. API_VERSION moves only
when an on-disk format changes, because a reader uses it to decide whether a file
can be opened at all. A file written by a newer release is refused with a message
that says to upgrade; a file whose framing is wrong is refused with a message that
says the bytes are not what they claim to be.
describeArtifact(blob) is the cheap way to find out which of the four formats a
file is, without a key.
Command line
logview-archive version
logview-archive capabilities
logview-archive keygen [--size bytes] [--out file]
logview-archive encrypt <in|-> [--out file] [--key hex | --passphrase text] [--profile name]
logview-archive decrypt <in|-> [--out file] [--key hex | --passphrase text]
logview-archive inspect <in|->
logview-archive digest <in|-> [--algorithm name]
It exists so the primitives can be exercised without writing a script, and so a
release can be checked by hand.
Tests
npm test
Each suite is also runnable on its own: node test/text.test.js. The suites use
no test framework; the runner only keeps the exit status meaningful across
several.
License
UNLICENSED — internal platform tooling. Use is limited to the platform that
publishes it.