This document provides the complete API reference for git-cas.
For cryptographic design, nonce and KDF guidance, and security-relevant implementation details, see SECURITY.md. For attacker models, trust boundaries, exposed metadata, and explicit non-goals, see docs/THREAT_MODEL.md.
The main facade class providing high-level API for content-addressable storage.
new ContentAddressableStore(options);Parameters:
options.plumbing(required): Plumbing instance from@git-stunts/plumbingoptions.chunkSize(optional): Chunk size in bytes (default: 262144 / 256 KiB)options.codec(optional): CodecPort implementation (default: JsonCodec)options.crypto(optional): CryptoPort implementation (default: auto-detected)options.policy(optional): Resilience policy from@git-stunts/alfredfor Git I/Ooptions.merkleThreshold(optional): Chunk count threshold for Merkle manifests (default: 1000)
Example:
import ContentAddressableStore from 'git-cas';
import Plumbing from '@git-stunts/plumbing';
const plumbing = await Plumbing.create({ repoPath: '/path/to/repo' });
const cas = new ContentAddressableStore({ plumbing });ContentAddressableStore.createJson({ plumbing, chunkSize, policy });Creates a CAS instance with JSON codec.
Parameters:
plumbing(required): Plumbing instancechunkSize(optional): Chunk size in bytespolicy(optional): Resilience policy
Returns: ContentAddressableStore
Example:
const cas = ContentAddressableStore.createJson({ plumbing });ContentAddressableStore.createCbor({ plumbing, chunkSize, policy });Creates a CAS instance with CBOR codec.
Parameters:
plumbing(required): Plumbing instancechunkSize(optional): Chunk size in bytespolicy(optional): Resilience policy
Returns: ContentAddressableStore
Example:
const cas = ContentAddressableStore.createCbor({ plumbing });await cas.getService();Lazily initializes and returns the underlying CasService instance.
Returns: Promise<CasService>
Example:
const service = await cas.getService();await cas.store({ source, slug, filename, encryptionKey, passphrase, encryption, kdfOptions, compression });Stores content from an async iterable source.
Parameters:
source(required):AsyncIterable<Buffer>- Content streamslug(required):string- Unique identifier for the assetfilename(required):string- Original filenameencryptionKey(optional):Buffer- 32-byte encryption keypassphrase(optional):string- Derive encryption key from passphrase (alternative toencryptionKey)encryption(optional):Object- Explicit encryption mode selection for encrypted stores. If omitted, encrypted stores now default toframedencryption.scheme(optional):'whole' | 'framed' | 'convergent'-wholeis the explicit compatibility whole-object AES-GCM format;framedstores independently authenticated frames so restore can stream verified plaintext incrementally and is now the default encrypted-write mode;convergentderives per-chunk keys from content, enabling deduplication across encrypted stores and is the default when using CDC chunking with encryptionencryption.frameBytes(optional):number- Plaintext bytes per framed record (default65536)kdfOptions(optional):Object- KDF options when usingpassphrase({ algorithm, iterations, cost, ... }). New passphrase stores default to PBKDF2600000iterations or scryptN=131072, and out-of-policy values fail withKDF_POLICY_VIOLATIONcompression(optional):{ algorithm: 'gzip' }- Enable compression before encryption/chunking
Returns: Promise<Manifest>
Throws:
CasErrorwith codeINVALID_KEY_TYPEif encryptionKey is not a BufferCasErrorwith codeINVALID_KEY_LENGTHif encryptionKey is not 32 bytesCasErrorwith codeSTREAM_ERRORif the source stream failsCasErrorwith codeINVALID_OPTIONSif bothpassphraseandencryptionKeyare providedCasErrorwith codeINVALID_OPTIONSif an unsupported encryption scheme is specifiedCasErrorwith codeINVALID_OPTIONSif an unsupported compression algorithm is specified
Example:
import { createReadStream } from 'node:fs';
import { randomBytes } from 'node:crypto';
const stream = createReadStream('/path/to/file.txt');
const key = randomBytes(32);
const manifest = await cas.store({
source: stream,
slug: 'my-asset',
filename: 'file.txt',
encryptionKey: key,
});await cas.storeFile({
filePath,
slug,
filename,
encryptionKey,
passphrase,
encryption,
kdfOptions,
compression,
});Convenience method that opens a file and stores it.
Parameters:
filePath(required):string- Path to fileslug(required):string- Unique identifier for the assetfilename(optional):string- Filename (defaults to basename of filePath)encryptionKey(optional):Buffer- 32-byte encryption keypassphrase(optional):string- Derive encryption key from passphraseencryption(optional):Object- Explicit encryption mode selection for encrypted stores. If omitted, encrypted stores now default toframedencryption.scheme(optional):'whole' | 'framed' | 'convergent'-wholeis the explicit compatibility whole-object AES-GCM format;framedstores independently authenticated frames so restore can stream verified plaintext incrementally and is now the default encrypted-write mode;convergentderives per-chunk keys from content, enabling deduplication across encrypted stores and is the default when using CDC chunking with encryptionencryption.frameBytes(optional):number- Plaintext bytes per framed record (default65536)kdfOptions(optional):Object- KDF options when usingpassphrase. New passphrase stores default to PBKDF2600000iterations or scryptN=131072, and out-of-policy values fail withKDF_POLICY_VIOLATIONcompression(optional):{ algorithm: 'gzip' }- Enable compression
Returns: Promise<Manifest>
Throws: Same as store()
Example:
const manifest = await cas.storeFile({
filePath: '/path/to/file.txt',
slug: 'my-asset',
encryptionKey: key,
});await cas.restore({ manifest, encryptionKey, passphrase });Restores content from a manifest and returns the buffer.
For encrypted content, whole still buffers the full ciphertext before
authenticating and decrypting. framed restores authenticated plaintext
frame-by-frame and only the final restore() collector buffers the result.
Parameters:
manifest(required):Manifest- Manifest objectencryptionKey(optional):Buffer- 32-byte encryption key (required if content is encrypted)passphrase(optional):string- Passphrase for KDF-based decryption (alternative toencryptionKey)
Returns: Promise<{ buffer: Buffer, bytesWritten: number }>
Throws:
CasErrorwith codeMISSING_KEYif content is encrypted but no key providedCasErrorwith codeINVALID_KEY_TYPEif encryptionKey is not a BufferCasErrorwith codeINVALID_KEY_LENGTHif encryptionKey is not 32 bytesCasErrorwith codeINTEGRITY_ERRORif chunk digest verification failsCasErrorwith codeINTEGRITY_ERRORif decryption failsCasErrorwith codeINTEGRITY_ERRORif decompression failsCasErrorwith codeINVALID_OPTIONSif bothpassphraseandencryptionKeyare provided
Example:
const { buffer, bytesWritten } = await cas.restore({ manifest });await cas.restoreFile({ manifest, encryptionKey, passphrase, outputPath });Restores content from a manifest and writes it to a file.
For plaintext and framed, this writes from the streaming restore path.
For whole and compression-buffered modes, restoreFile() now uses a
bounded temp-file path: bytes are verified, decrypted, and optionally gunzipped
into a temporary sibling path, then renamed into place only after the pipeline
completes successfully. This improves file restores without changing the
contract of restoreStream(), which remains buffered for whole.
On Web Crypto runtimes, the whole-object decrypt step is still internally
one-shot; the parity improvement is that this path now stays bounded by the
adapter's decryption buffer limit instead of collecting ciphertext without a
guard.
Parameters:
manifest(required):Manifest- Manifest objectencryptionKey(optional):Buffer- 32-byte encryption keypassphrase(optional):string- Passphrase for KDF-based decryptionoutputPath(required):string- Path to write the restored file
Returns: Promise<{ bytesWritten: number }>
Throws: Same as restore()
Example:
await cas.restoreFile({
manifest,
outputPath: '/path/to/output.txt',
});await cas.createTree({ manifest });Creates a Git tree object from a manifest.
Parameters:
manifest(required):Manifest- Manifest object
Returns: Promise<string> - Git tree OID
Example:
const treeOid = await cas.createTree({ manifest });await cas.verifyIntegrity(manifest);Verifies the integrity of stored content by re-hashing all chunks. For
encrypted manifests, pass the same decryption credentials you would use for
restore() so the ciphertext is also authenticated. whole authenticates
the full ciphertext as one unit; framed authenticates every stored frame.
Parameters:
manifest(required):Manifest- Manifest objectoptions(optional):objectoptions.encryptionKey(optional):Buffer- 32-byte key for encrypted manifestsoptions.passphrase(optional):string- Passphrase for KDF-based encrypted manifests
Returns: Promise<boolean> - True if all chunks pass verification
Example:
const isValid = await cas.verifyIntegrity(manifest);
if (!isValid) {
console.log('Integrity check failed');
}Encrypted example:
const isValid = await cas.verifyIntegrity(manifest, {
encryptionKey: key,
});await cas.readManifest({ treeOid });Reads a Git tree, locates the manifest entry, decodes it, and returns a validated Manifest value object.
Parameters:
treeOid(required):string- Git tree OID
Returns: Promise<Manifest> - Frozen, Zod-validated Manifest
Throws:
CasErrorwith codeMANIFEST_NOT_FOUNDif no manifest entry exists in the treeCasErrorwith codeGIT_ERRORif the underlying Git command fails- Zod validation error if the manifest blob is corrupt
Example:
const treeOid = 'a1b2c3d4e5f6...';
const manifest = await cas.readManifest({ treeOid });
console.log(manifest.slug); // "photos/vacation"
console.log(manifest.chunks); // array of Chunk objectsconst stream = cas.restoreStream({ manifest, encryptionKey, passphrase });Restores content from a manifest as an async iterable of Buffer chunks.
For unencrypted, uncompressed files this is true per-chunk streaming with O(chunkSize) memory. whole encrypted paths still collect internally before yielding, while framed encrypted payloads authenticate and emit plaintext incrementally.
Parameters:
manifest(required):Manifest- Manifest objectencryptionKey(optional):Buffer- 32-byte encryption key (required if content is encrypted)passphrase(optional):string- Passphrase for KDF-based decryption (alternative toencryptionKey)
Returns: AsyncIterable<Buffer>
Throws:
CasErrorwith codeMISSING_KEYif content is encrypted but no key providedCasErrorwith codeINTEGRITY_ERRORif chunk verification or decryption fails
Example:
for await (const chunk of cas.restoreStream({ manifest })) {
process.stdout.write(chunk);
}await cas.inspectAsset({ treeOid });Reads a manifest from a Git tree and returns inspection metadata. Does not perform any destructive Git operations.
Parameters:
treeOid(required):string- Git tree OID of the asset
Returns: Promise<{ slug: string, chunksOrphaned: number }>
Throws:
CasErrorwith codeMANIFEST_NOT_FOUNDif the tree has no manifestCasErrorwith codeGIT_ERRORif the underlying Git command fails
Example:
const { slug, chunksOrphaned } = await cas.inspectAsset({ treeOid });
console.log(`Asset "${slug}" has ${chunksOrphaned} chunks`);ContentAddressableStore.diffManifests(oldManifest, newManifest);Compares two manifests by chunk digest to find added, removed, and unchanged chunks. Pure function — no I/O. Does not require initialization.
Parameters:
oldManifest(required):Manifest- Previous manifestnewManifest(required):Manifest- Updated manifest
Returns: ManifestDiffResult — object with added, removed, and unchanged chunk arrays
Example:
const diff = ContentAddressableStore.diffManifests(oldManifest, newManifest);
console.log(`Added: ${diff.added.length}, Removed: ${diff.removed.length}`);await cas.addRecipient({ manifest, existingKey, newRecipientKey, label });Adds a recipient to an envelope-encrypted manifest. Unwraps the DEK using existingKey, then re-wraps it with newRecipientKey for the new recipient.
Parameters:
manifest(required):Manifest- Envelope-encrypted manifestexistingKey(required):Buffer- KEK of an existing recipient (used to unwrap the DEK)newRecipientKey(required):Buffer- KEK for the new recipientlabel(required):string- Label for the new recipient
Returns: Promise<Manifest> - Updated manifest with the new recipient entry
Throws:
CasErrorwith codeINVALID_OPTIONSif manifest has no recipientsCasErrorwith codeRECIPIENT_ALREADY_EXISTSif label is a duplicateCasErrorwith codeDEK_UNWRAP_FAILEDif existingKey doesn't match any recipient
Example:
const updated = await cas.addRecipient({
manifest,
existingKey: aliceKey,
newRecipientKey: bobKey,
label: 'bob',
});await cas.removeRecipient({ manifest, label });Removes a recipient from an envelope-encrypted manifest.
Parameters:
manifest(required):Manifest- Envelope-encrypted manifestlabel(required):string- Label of the recipient to remove
Returns: Promise<Manifest> - Updated manifest without the removed recipient
Throws:
CasErrorwith codeRECIPIENT_NOT_FOUNDif label doesn't existCasErrorwith codeCANNOT_REMOVE_LAST_RECIPIENTif only one recipient remains
Example:
const updated = await cas.removeRecipient({ manifest, label: 'bob' });await cas.listRecipients(manifest);Lists recipient labels from an envelope-encrypted manifest.
Parameters:
manifest(required):Manifest- Manifest to inspect
Returns: Promise<string[]> - Recipient labels, or empty array if not envelope-encrypted
Example:
const labels = await cas.listRecipients(manifest);
console.log('Recipients:', labels.join(', '));await cas.collectReferencedChunks({ treeOids });Aggregates referenced chunk blob OIDs across multiple stored assets. Analysis only — does not delete or modify anything.
Parameters:
treeOids(required):Array<string>- Git tree OIDs to analyze
Returns: Promise<{ referenced: Set<string>, total: number }>
referenced— deduplicated Set of all chunk blob OIDs across the given treestotal— total number of chunk references (before deduplication)
Throws:
CasErrorwith codeMANIFEST_NOT_FOUNDif anytreeOidlacks a manifest (fail closed)CasErrorwith codeGIT_ERRORif the underlying Git command fails
Example:
const { referenced, total } = await cas.collectReferencedChunks({
treeOids: [treeOid1, treeOid2, treeOid3],
});
console.log(`${referenced.size} unique blobs across ${total} total chunk references`);Deprecated. Use
inspectAssetinstead.
await cas.deleteAsset({ treeOid });Returns logical deletion metadata for an asset. Does not perform any destructive Git operations — the caller must remove refs, and physical deletion requires git gc --prune.
Parameters:
treeOid(required):string- Git tree OID
Returns: Promise<{ slug: string, chunksOrphaned: number }>
Throws:
CasErrorwith codeMANIFEST_NOT_FOUND(delegates toreadManifest)CasErrorwith codeGIT_ERRORif the underlying Git command fails
Example:
const { slug, chunksOrphaned } = await cas.deleteAsset({ treeOid });
console.log(`Asset "${slug}" has ${chunksOrphaned} chunks to clean up`);
// Caller must remove refs pointing to treeOid; run `git gc --prune` to reclaim spaceawait cas.deriveKey(options);Derives an encryption key from a passphrase using PBKDF2 or scrypt.
Parameters:
options.passphrase(required):string- The passphraseoptions.salt(optional):Buffer- Salt (random if omitted)options.algorithm(optional):'pbkdf2' | 'scrypt'- KDF algorithm (default:'pbkdf2')options.iterations(optional):number- PBKDF2 iterations (default: 600000)options.cost(optional):number- scrypt cost parameter N (default: 131072)options.blockSize(optional):number- scrypt block size r (default: 8)options.parallelization(optional):number- scrypt parallelization p (default: 1)options.keyLength(optional):number- Derived key length (default: 32)
Returns: Promise<{ key: Buffer, salt: Buffer, params: Object }>
key— the derived 32-byte encryption keysalt— the salt used (save this for re-derivation)params— full KDF parameters object (stored in manifest when usingpassphraseoption)
Example:
const { key, salt, params } = await cas.deriveKey({
passphrase: 'my secret passphrase',
algorithm: 'pbkdf2',
iterations: 600000,
});
// Use the derived key for encryption
const manifest = await cas.storeFile({
filePath: '/path/to/file.txt',
slug: 'my-asset',
encryptionKey: key,
});Deprecated. Use
collectReferencedChunksinstead.
await cas.findOrphanedChunks({ treeOids });Aggregates all chunk blob OIDs referenced across multiple assets and returns a report. Analysis only — does not delete or modify anything.
Parameters:
treeOids(required):Array<string>- Array of Git tree OIDs
Returns: Promise<{ referenced: Set<string>, total: number }>
referenced— deduplicated Set of all chunk blob OIDs across the given treestotal— total number of chunk references (before deduplication)
Throws:
CasErrorwith codeMANIFEST_NOT_FOUNDif anytreeOidlacks a manifest (fail closed)CasErrorwith codeGIT_ERRORif the underlying Git command fails
Example:
const { referenced, total } = await cas.findOrphanedChunks({
treeOids: [treeOid1, treeOid2, treeOid3],
});
console.log(`${referenced.size} unique blobs across ${total} total chunk references`);await cas.encrypt({ buffer, key });Encrypts a buffer using AES-256-GCM.
Parameters:
buffer(required):Buffer- Data to encryptkey(required):Buffer- 32-byte encryption key
Returns: Promise<{ buf: Buffer, meta: Object }>
Throws:
CasErrorwith codeINVALID_KEY_TYPEif key is not a BufferCasErrorwith codeINVALID_KEY_LENGTHif key is not 32 bytes
Example:
const { buf, meta } = await cas.encrypt({
buffer: Buffer.from('secret data'),
key: crypto.randomBytes(32),
});await cas.decrypt({ buffer, key, meta });Decrypts a buffer using AES-256-GCM.
Parameters:
buffer(required):Buffer- Encrypted datakey(required):Buffer- 32-byte encryption keymeta(required):Object- Encryption metadata (from encrypt result)
Returns: Promise<Buffer> - Decrypted data
Throws:
CasErrorwith codeINTEGRITY_ERRORif decryption fails
Example:
const decrypted = await cas.decrypt({ buffer: buf, key, meta });await cas.rotateKey({ manifest, oldKey, newKey, label });Rotates a recipient's encryption key without re-encrypting data blobs. Unwraps the DEK with oldKey, re-wraps with newKey, and increments keyVersion counters.
Parameters:
manifest(required):Manifest- Envelope-encrypted manifestoldKey(required):Buffer- Current 32-byte KEKnewKey(required):Buffer- New 32-byte KEKlabel(optional):string- If provided, only rotate the named recipient
Returns: Promise<Manifest> - Updated manifest with re-wrapped DEK and incremented keyVersion
Throws:
CasErrorwith codeROTATION_NOT_SUPPORTEDif manifest has no recipients (legacy/unencrypted)CasErrorwith codeRECIPIENT_NOT_FOUNDiflabeldoesn't existCasErrorwith codeDEK_UNWRAP_FAILEDifoldKeydoesn't match the recipientCasErrorwith codeNO_MATCHING_RECIPIENTif no label is provided andoldKeymatches no entry
Example:
const rotated = await cas.rotateKey({
manifest,
oldKey: aliceOldKey,
newKey: aliceNewKey,
label: 'alice',
});
const treeOid = await cas.createTree({ manifest: rotated });
await cas.addToVault({ slug: 'my-asset', treeOid, force: true });await cas.rotateVaultPassphrase({ oldPassphrase, newPassphrase, kdfOptions });Rotates the vault-level encryption passphrase. Re-wraps every envelope-encrypted entry's DEK with a new KEK derived from newPassphrase. Non-envelope entries are skipped.
Parameters:
oldPassphrase(required):string- Current vault passphrasenewPassphrase(required):string- New vault passphrasekdfOptions(optional):Object- KDF options for new passphrase (e.g.,{ algorithm: 'scrypt' }). Defaults use PBKDF2600000or scryptN=131072, and out-of-policy values fail withKDF_POLICY_VIOLATION
Returns: Promise<{ commitOid: string, rotatedSlugs: string[], skippedSlugs: string[] }>
Throws:
CasErrorwith codeVAULT_METADATA_INVALIDif vault is not encryptedCasErrorwith codeDEK_UNWRAP_FAILEDorNO_MATCHING_RECIPIENTif old passphrase is wrongCasErrorwith codeKDF_POLICY_VIOLATIONif stored or requested KDF parameters fall outside policyCasErrorwith codeVAULT_CONFLICTif concurrent vault updates exhaust retries
Example:
const { commitOid, rotatedSlugs, skippedSlugs } = await cas.rotateVaultPassphrase({
oldPassphrase: 'old-secret',
newPassphrase: 'new-secret',
});
console.log(`Rotated: ${rotatedSlugs.join(', ')}`);
console.log(`Skipped: ${skippedSlugs.join(', ')}`);cas.chunkSize;Returns the configured chunk size in bytes.
Type: number
Example:
console.log(cas.chunkSize); // 262144The vault provides GC-safe storage by maintaining a single Git ref (refs/cas/vault) pointing to a commit chain. The commit's tree indexes all stored assets by slug. This prevents git gc from garbage-collecting stored data.
refs/cas/vault → commit → tree
├── 100644 blob <oid> .vault.json
├── 040000 tree <oid> demo/hello
├── 040000 tree <oid> photos/beach
interface VaultEntry {
slug: string;
treeOid: string;
}interface VaultMetadata {
version: number;
encryption?: {
cipher: string;
kdf: {
algorithm: string;
salt: string;
iterations?: number;
cost?: number;
blockSize?: number;
parallelization?: number;
keyLength: number;
};
};
}await cas.initVault({ passphrase?, kdfOptions? })Initializes the vault. Optionally configures vault-level encryption with a passphrase.
Parameters:
passphrase(optional):string- Passphrase for vault-level key derivationkdfOptions(optional):Object- KDF options ({ algorithm, iterations, cost, ... }). Defaults use PBKDF2600000or scryptN=131072, and out-of-policy values fail withKDF_POLICY_VIOLATION
Returns: Promise<{ commitOid: string }>
Throws:
CasErrorwith codeVAULT_ENCRYPTION_ALREADY_CONFIGUREDif vault already has encryptionCasErrorwith codeKDF_POLICY_VIOLATIONif requested KDF parameters fall outside policy
Example:
// Without encryption
await cas.initVault();
// With encryption
await cas.initVault({
passphrase: 'my secret passphrase',
kdfOptions: { algorithm: 'pbkdf2' },
});await cas.addToVault({ slug, treeOid, force? })Adds an entry to the vault. Auto-initializes the vault if it doesn't exist.
Parameters:
slug(required):string- Entry slug (e.g.,"demo/hello","photos/beach-2024")treeOid(required):string- Git tree OIDforce(optional):boolean- Overwrite existing entry (default:false)
Returns: Promise<{ commitOid: string }>
Throws:
CasErrorwith codeINVALID_SLUGif slug fails validationCasErrorwith codeVAULT_ENTRY_EXISTSif slug exists andforceis falseCasErrorwith codeVAULT_CONFLICTif concurrent update detected after retries
Example:
const treeOid = await cas.createTree({ manifest });
await cas.addToVault({ slug: 'demo/hello', treeOid });await cas.listVault();Lists all vault entries sorted by slug.
Returns: Promise<VaultEntry[]>
Example:
const entries = await cas.listVault();
for (const { slug, treeOid } of entries) {
console.log(`${slug}\t${treeOid}`);
}await cas.removeFromVault({ slug });Removes an entry from the vault.
Parameters:
slug(required):string- Entry slug to remove
Returns: Promise<{ commitOid: string, removedTreeOid: string }>
Throws:
CasErrorwith codeVAULT_ENTRY_NOT_FOUNDif slug does not exist
Example:
const { removedTreeOid } = await cas.removeFromVault({ slug: 'demo/hello' });await cas.resolveVaultEntry({ slug });Resolves a vault entry slug to its tree OID.
Parameters:
slug(required):string- Entry slug
Returns: Promise<string> - The tree OID
Throws:
CasErrorwith codeVAULT_ENTRY_NOT_FOUNDif slug does not exist
Example:
const treeOid = await cas.resolveVaultEntry({ slug: 'demo/hello' });
const manifest = await cas.readManifest({ treeOid });await cas.getVaultMetadata();Returns the vault metadata, or null if no vault exists.
Returns: Promise<VaultMetadata | null>
Example:
const metadata = await cas.getVaultMetadata();
if (metadata?.encryption) {
console.log('Vault is encrypted with', metadata.encryption.kdf.algorithm);
}Slugs are validated with the following rules:
- Must be a non-empty string
- Must not start or end with
/ - Must not contain empty segments (
a//b) - Must not contain
.or..segments - Must not contain control characters (0x00–0x1f, 0x7f)
- Each segment must not exceed 255 bytes
- Total slug must not exceed 1024 bytes
When a vault is initialized with a passphrase, the human CLI can derive an
asset encryption key from the vault's KDF configuration when you supply
--vault-passphrase, --vault-passphrase-file, or --os-keychain-target
during store and restore:
// Initialize vault with encryption
await cas.initVault({ passphrase: 'secret' });
// Store with vault-configured passphrase derivation (human CLI convenience)
// git-cas store file.txt --slug demo/hello --tree --vault-passphrase secret
// Restore with vault-configured passphrase derivation
// git-cas restore --slug demo/hello --out file.txt --vault-passphrase secret
// Or resolve the vault passphrase from the OS keychain
// git-cas restore --slug demo/hello --out file.txt --os-keychain-target demo/passphraseThe vault stores the KDF parameters (algorithm, salt, iterations) in
.vault.json; the passphrase is never stored.
This does not make refs/cas/vault itself confidential. The vault remains a
readable slug-to-tree index for repository readers. See
SECURITY.md for the cryptographic design details and
docs/THREAT_MODEL.md for the explicit boundary.
This is not an implicit library-level store() or restore() behavior.
Library callers still pass explicit encryptionKey or passphrase values, or
derive keys themselves through getVaultMetadata() plus deriveKey() before
calling the content APIs.
When --os-keychain-target is used, the human CLI resolves the passphrase
through @git-stunts/vault using OS-native secure storage. The optional
--os-keychain-account flag scopes the lookup; the default account is
git-cas.
The machine-facing git cas agent surface now supports the same explicit
keychain lookup model for vault-derived passphrase flows through structured
request fields:
osKeychainTarget/osKeychainAccountfor agent store, restore, and vault initoldOsKeychainTarget/oldOsKeychainAccountandnewOsKeychainTarget/newOsKeychainAccountfor agent vault rotate
git cas vault init # Initialize vault
git cas vault init --vault-passphrase "secret" # With encryption
git cas vault init --os-keychain-target demo/passphrase
git cas vault list # List all entries
git cas vault info <slug> # Show slug + tree OID
git cas vault remove <slug> # Remove an entry
git cas vault history # Show commit history
git cas vault history -n 10 # Last N commits
git cas vault rotate --old-passphrase "old" --new-passphrase "new"
git cas vault rotate --old-passphrase "old" --new-passphrase "new" --algorithm scrypt# Rotate a single asset's key (by vault slug)
git cas rotate --slug demo/hello \
--old-key-file old.key --new-key-file new.key
# Rotate a single asset's key (by tree OID)
git cas rotate --oid <tree-oid> \
--old-key-file old.key --new-key-file new.key
# Rotate only a named recipient
git cas rotate --slug demo/hello \
--old-key-file old.key --new-key-file new.key --label alice| Flag | Description |
|---|---|
--slug <slug> |
Resolve tree OID from vault slug (updates vault entry) |
--oid <tree-oid> |
Direct tree OID (outputs updated manifest) |
--old-key-file <path> |
Path to current 32-byte key file (required) |
--new-key-file <path> |
Path to new 32-byte key file (required) |
--label <label> |
Only rotate the named recipient entry |
--cwd <dir> |
Git working directory (default: .) |
| Flag | Description |
|---|---|
--old-passphrase <pass> |
Current vault passphrase (required) |
--new-passphrase <pass> |
New vault passphrase (required) |
--algorithm <alg> |
KDF algorithm for new passphrase (pbkdf2 or scrypt) |
--cwd <dir> |
Git working directory (default: .) |
The vault maintains a full commit history via refs/cas/vault. Each mutation (add, remove, init) creates a new commit. Use vault history (or git log refs/cas/vault) to inspect the audit trail.
Domain service for vault operations. Requires three ports:
persistence(GitPersistencePort) — blob/tree read/writeref(GitRefPort) — ref resolution, commits, atomic updatescrypto(CryptoPort) — KDF for vault-level encryption
import { VaultService } from '@git-stunts/git-cas'; // or via facade
const vault = await cas.getVaultService();Core domain service implementing CAS operations. Usually accessed via ContentAddressableStore, but can be used directly for advanced scenarios.
new CasService({ persistence, codec, crypto, chunkSize, merkleThreshold });Parameters:
persistence(required):GitPersistencePortimplementationcodec(required):CodecPortimplementationcrypto(required):CryptoPortimplementationchunkSize(optional):number- Chunk size in bytes (default: 262144, minimum: 1024)merkleThreshold(optional):number- Chunk count threshold for Merkle manifests (default: 1000)
Throws:
Errorif chunkSize is less than 1024 bytesErrorif merkleThreshold is not a positive integer
Example:
import CasService from 'git-cas/src/domain/services/CasService.js';
import GitPersistenceAdapter from 'git-cas/src/infrastructure/adapters/GitPersistenceAdapter.js';
import JsonCodec from 'git-cas/src/infrastructure/codecs/JsonCodec.js';
import NodeCryptoAdapter from 'git-cas/src/infrastructure/adapters/NodeCryptoAdapter.js';
const service = new CasService({
persistence: new GitPersistenceAdapter({ plumbing }),
codec: new JsonCodec(),
crypto: new NodeCryptoAdapter(),
chunkSize: 512 * 1024,
});All methods from ContentAddressableStore delegate to CasService. See ContentAddressableStore documentation above for:
store({ source, slug, filename, encryptionKey, passphrase, kdfOptions, compression })restore({ manifest, encryptionKey, passphrase })createTree({ manifest })verifyIntegrity(manifest, { encryptionKey, passphrase })readManifest({ treeOid })deleteAsset({ treeOid })findOrphanedChunks({ treeOids })encrypt({ buffer, key })decrypt({ buffer, key, meta })deriveKey(options)
CasService extends Node.js EventEmitter. See Events section for all emitted events.
CasService emits the following events. Listen using standard EventEmitter API:
const service = await cas.getService();
service.on('chunk:stored', (payload) => {
console.log('Chunk stored:', payload);
});Emitted when a chunk is successfully stored.
Payload:
{
index: number, // Chunk index (0-based)
size: number, // Chunk size in bytes
digest: string, // SHA-256 hex digest (64 chars)
blob: string // Git blob OID
}Emitted when a chunk is successfully restored and verified.
Payload:
{
index: number, // Chunk index (0-based)
size: number, // Chunk size in bytes
digest: string // SHA-256 hex digest (64 chars)
}Emitted when a complete file is successfully stored.
Payload:
{
slug: string, // Asset slug
size: number, // Total file size in bytes
chunkCount: number, // Number of chunks
encrypted: boolean // Whether content was encrypted
}Emitted when a complete file is successfully restored.
Payload:
{
slug: string, // Asset slug
size: number, // Total file size in bytes
chunkCount: number // Number of chunks
}Emitted when integrity verification passes for all chunks.
Payload:
{
slug: string; // Asset slug
}Emitted when integrity verification fails for a chunk.
Payload:
{
slug: string, // Asset slug
chunkIndex: number, // Failed chunk index
expected: string, // Expected SHA-256 digest
actual: string // Actual SHA-256 digest
}Emitted when an error occurs during streaming operations (if listeners are registered).
Payload:
{
code: string, // CasError code
message: string // Error message
}Immutable value object representing a file manifest.
new Manifest(data);Parameters:
data.slug(required):string- Unique identifier (min length: 1)data.filename(required):string- Original filename (min length: 1)data.size(required):number- Total file size in bytes (>= 0)data.chunks(required):Array<Object>- Chunk metadata arraydata.encryption(optional):Object- Encryption metadata (may includekdffield for passphrase-derived keys)data.version(optional):number- Manifest version (1 = flat, 2 = Merkle; default: 1)data.compression(optional):Object- Compression metadata{ algorithm: 'gzip' }data.subManifests(optional):Array<Object>- Sub-manifest references (v2 Merkle manifests only)
Throws: Error if data does not match ManifestSchema
Example:
const manifest = new Manifest({
slug: 'my-asset',
filename: 'file.txt',
size: 1024,
chunks: [
{
index: 0,
size: 1024,
digest: 'a'.repeat(64),
blob: 'abc123def456',
},
],
});slug:string- Asset identifierfilename:string- Original filenamesize:number- Total file sizechunks:Array<Chunk>- Array of Chunk objectsencryption:Object | undefined- Encryption metadata (may includekdfsub-object)version:number- Manifest version (1 or 2, default: 1)compression:Object | undefined- Compression metadata{ algorithm }subManifests:Array | undefined- Sub-manifest references (v2 only)
manifest.toJSON();Returns a plain object representation suitable for serialization.
Returns: Object
Example:
const json = manifest.toJSON();
console.log(JSON.stringify(json, null, 2));Immutable value object representing a content chunk.
new Chunk(data);Parameters:
data.index(required):number- Chunk index (>= 0)data.size(required):number- Chunk size in bytes (> 0)data.digest(required):string- SHA-256 hex digest (exactly 64 chars)data.blob(required):string- Git blob OID (min length: 1)
Throws: Error if data does not match ChunkSchema
Example:
const chunk = new Chunk({
index: 0,
size: 262144,
digest: 'a'.repeat(64),
blob: 'abc123def456',
});index:number- Chunk index (0-based)size:number- Chunk size in bytesdigest:string- SHA-256 hex digestblob:string- Git blob OID
Ports define the interfaces for pluggable adapters. Implementations are provided but you can create custom adapters.
Interface for Git persistence operations.
await port.writeBlob(content);Writes content as a Git blob.
Parameters:
content:Buffer | string- Content to store
Returns: Promise<string> - Git blob OID
await port.writeTree(entries);Creates a Git tree object.
Parameters:
entries:Array<string>- Git mktree format lines (e.g.,"100644 blob <oid>\t<name>")
Returns: Promise<string> - Git tree OID
await port.readBlob(oid);Reads a Git blob.
Parameters:
oid:string- Git blob OID
Returns: Promise<Buffer> - Blob content
await port.readBlobStream(oid);Reads a Git blob as an async stream of Buffer chunks.
For custom persistence adapters, this method is required for hard-limited
buffered restore modes such as whole encrypted restore and buffered
compression restore. readBlob() remains a compatibility fallback for
plaintext restore only.
Parameters:
oid:string- Git blob OID
Returns: Promise<AsyncIterable<Buffer>> - Blob byte stream
await port.readTree(treeOid);Reads a Git tree object.
Parameters:
treeOid:string- Git tree OID
Returns: Promise<Array<{ mode: string, type: string, oid: string, name: string }>>
Example Implementation:
import GitPersistencePort from 'git-cas/src/ports/GitPersistencePort.js';
class CustomGitAdapter extends GitPersistencePort {
async writeBlob(content) {
// Implementation
}
async writeTree(entries) {
// Implementation
}
async readBlobStream(oid) {
// Implementation
}
async readBlob(oid) {
// Implementation
}
async readTree(treeOid) {
// Implementation
}
}Interface for encoding/decoding manifest data.
port.encode(data);Encodes data to Buffer or string.
Parameters:
data:Object- Data to encode
Returns: Buffer | string - Encoded data
port.decode(buffer);Decodes data from Buffer or string.
Parameters:
buffer:Buffer | string- Encoded data
Returns: Object - Decoded data
port.extension;File extension for this codec (e.g., 'json', 'cbor').
Returns: string
Example Implementation:
import CodecPort from 'git-cas/src/ports/CodecPort.js';
class XmlCodec extends CodecPort {
encode(data) {
return convertToXml(data);
}
decode(buffer) {
return parseXml(buffer.toString('utf8'));
}
get extension() {
return 'xml';
}
}Interface for cryptographic operations.
port.sha256(buf);Computes SHA-256 hash.
Parameters:
buf:Buffer- Data to hash
Returns: string - 64-character hex digest
port.randomBytes(n);Generates cryptographically random bytes.
Parameters:
n:number- Number of bytes
Returns: Buffer - Random bytes
port.encryptBuffer(buffer, key);Encrypts a buffer using AES-256-GCM.
Parameters:
buffer:Buffer- Data to encryptkey:Buffer- 32-byte encryption key
Returns: { buf: Buffer, meta: { algorithm: string, nonce: string, tag: string, encrypted: boolean } }
port.decryptBuffer(buffer, key, meta);Decrypts a buffer using AES-256-GCM.
Parameters:
buffer:Buffer- Encrypted datakey:Buffer- 32-byte encryption keymeta:Object- Encryption metadata withalgorithm,nonce,tag,encrypted
Returns: Buffer - Decrypted data
Throws: On authentication failure
port.createEncryptionStream(key);Creates a streaming encryption context.
Parameters:
key:Buffer- 32-byte encryption key
Returns: { encrypt: Function, finalize: Function }
encrypt:(source: AsyncIterable<Buffer>) => AsyncIterable<Buffer>- Transform functionfinalize:() => { algorithm: string, nonce: string, tag: string, encrypted: boolean }- Get metadata
await port.deriveKey(options);Derives an encryption key from a passphrase using PBKDF2 or scrypt.
Parameters:
options.passphrase:string- The passphraseoptions.salt(optional):Buffer- Salt (random if omitted)options.algorithm(optional):'pbkdf2' | 'scrypt'- KDF algorithm (default:'pbkdf2')options.iterations(optional):number- PBKDF2 iterations (default:600000)options.cost(optional):number- scrypt cost N (default:131072)options.blockSize(optional):number- scrypt block size roptions.parallelization(optional):number- scrypt parallelization poptions.keyLength(optional):number- Derived key length (default: 32)
deriveKey() is the raw derivation primitive. Policy enforcement for persisted
KDF metadata happens in store(), restore(), initVault(), and
rotateVaultPassphrase().
Returns: Promise<{ key: Buffer, salt: Buffer, params: Object }>
Example Implementation:
import CryptoPort from 'git-cas/src/ports/CryptoPort.js';
class CustomCryptoAdapter extends CryptoPort {
sha256(buf) {
// Implementation
}
randomBytes(n) {
// Implementation
}
encryptBuffer(buffer, key) {
// Implementation
}
decryptBuffer(buffer, key, meta) {
// Implementation
}
createEncryptionStream(key) {
// Implementation
}
async deriveKey(options) {
// Implementation
}
}Built-in codec implementations.
JSON codec for manifest serialization.
import { JsonCodec } from 'git-cas';
const codec = new JsonCodec();
const encoded = codec.encode({ key: 'value' });
const decoded = codec.decode(encoded);
console.log(codec.extension); // 'json'CBOR codec for compact binary serialization.
import { CborCodec } from 'git-cas';
const codec = new CborCodec();
const encoded = codec.encode({ key: 'value' });
const decoded = codec.decode(encoded);
console.log(codec.extension); // 'cbor'All errors thrown by git-cas are instances of CasError.
import CasError from 'git-cas/src/domain/errors/CasError.js';new CasError(message, code, meta);Parameters:
message:string- Error messagecode:string- Error code (see below)meta:Object- Additional error context (default:{})
name:string- Always "CasError"message:string- Error messagecode:string- Error codemeta:Object- Additional contextstack:string- Stack trace
| Code | Description | Thrown By |
|---|---|---|
INVALID_KEY_TYPE |
Encryption key must be a Buffer or Uint8Array | encrypt(), decrypt(), store(), restore() |
INVALID_KEY_LENGTH |
Encryption key must be exactly 32 bytes | encrypt(), decrypt(), store(), restore() |
MISSING_KEY |
Encryption key required to restore encrypted content but none was provided | restore() |
INTEGRITY_ERROR |
Chunk digest verification failed or decryption authentication failed | restore(), verifyIntegrity(), decrypt() |
PERSISTENCE_CAPABILITY_REQUIRED |
Buffered restore mode requires readBlobStream() on the persistence adapter |
restore(), restoreStream() |
DECRYPTION_BUFFER_EXCEEDED |
Web Crypto whole-object decrypt exceeded the configured buffer limit | createDecryptionStream() via Web Crypto restore paths |
KDF_POLICY_VIOLATION |
KDF parameters fell outside the accepted policy window | store(), restore(), initVault(), rotateVaultPassphrase(), readState() |
STREAM_ERROR |
Stream error occurred during store operation | store() |
STORE_ERROR |
Chunk write failed during store after dispatch | store() |
MANIFEST_NOT_FOUND |
No manifest entry found in the Git tree | readManifest(), deleteAsset(), findOrphanedChunks() |
GIT_ERROR |
Underlying Git plumbing command failed | readManifest(), deleteAsset(), findOrphanedChunks() |
INVALID_OPTIONS |
Mutually exclusive options provided or unsupported option value | store(), restore() |
INVALID_SLUG |
Slug fails validation (empty, control chars, .. segments, etc.) |
addToVault() |
VAULT_ENTRY_NOT_FOUND |
Slug does not exist in vault | removeFromVault(), resolveVaultEntry() |
VAULT_ENTRY_EXISTS |
Slug already exists (use force to overwrite) |
addToVault() |
VAULT_CONFLICT |
Concurrent vault update detected (CAS failure after retries) | addToVault(), removeFromVault(), initVault(), rotateVaultPassphrase() |
VAULT_METADATA_INVALID |
.vault.json malformed, unknown version, or missing required fields |
readState(), rotateVaultPassphrase() |
VAULT_ENCRYPTION_ALREADY_CONFIGURED |
Cannot reconfigure encryption without key rotation | initVault() |
NO_MATCHING_RECIPIENT |
No recipient entry matches the provided KEK | restore(), rotateKey() |
DEK_UNWRAP_FAILED |
Failed to unwrap DEK with the provided KEK | addRecipient(), rotateKey() |
RECIPIENT_NOT_FOUND |
Recipient label not found in manifest | removeRecipient(), rotateKey() |
RECIPIENT_ALREADY_EXISTS |
Recipient label already exists | addRecipient() |
CANNOT_REMOVE_LAST_RECIPIENT |
Cannot remove the last recipient | removeRecipient() |
ROTATION_NOT_SUPPORTED |
Key rotation requires envelope encryption (recipients) | rotateKey() |
Example:
import CasError from 'git-cas/src/domain/errors/CasError.js';
try {
await cas.restore({ manifest, encryptionKey });
} catch (err) {
if (err instanceof CasError) {
console.error('CAS Error:', err.code);
console.error('Message:', err.message);
console.error('Meta:', err.meta);
switch (err.code) {
case 'MISSING_KEY':
console.log('Content is encrypted - please provide a key');
break;
case 'INTEGRITY_ERROR':
console.log('Content verification failed - may be corrupted');
break;
case 'INVALID_KEY_LENGTH':
console.log('Key must be 32 bytes');
break;
}
} else {
throw err;
}
}Different error codes include different metadata:
INVALID_KEY_LENGTH:
{
expected: 32,
actual: <number>
}INTEGRITY_ERROR (chunk verification):
{
chunkIndex: <number>,
expected: <string>, // Expected SHA-256 digest
actual: <string> // Actual SHA-256 digest
}INTEGRITY_ERROR (decryption):
{
originalError: <Error>
}STREAM_ERROR:
{
chunksDispatched: <number>,
orphanedBlobs: <string[]>,
originalError: <Error>
}STORE_ERROR:
{
chunksDispatched: <number>,
orphanedBlobs: <string[]>,
failedIndex: <number>,
originalError: <Error>
}