Vault
The Problem
Local secrets solve the individual developer's needs—sensitive values encrypted on their machine. But teams need shared secrets: API keys everyone uses, service credentials that shouldn't live in code. Email threads and chat messages aren't secure. Separate secret management tools add friction.
The vault stores encrypted secrets in the database itself. Team members automatically receive access when an authorized user propagates the key. Secrets stay encrypted at rest, decrypted only in memory when needed.
Architecture
The vault uses dual-layer encryption:
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Vault Key Distribution │
│ │
│ vault_key (32 bytes) ──────┬──────────────────────────────│
│ │ │
│ ┌─────────────────────┐ │ ┌─────────────────────────┐ │
│ │ Alice's public key │──┼──│ encrypted_vault_key[A] │ │
│ └─────────────────────┘ │ └─────────────────────────┘ │
│ ┌─────────────────────┐ │ ┌─────────────────────────┐ │
│ │ Bob's public key │──┼──│ encrypted_vault_key[B] │ │
│ └─────────────────────┘ │ └─────────────────────────┘ │
│ │ │
└─────────────────────────────┼───────────────────────────────┘
│
┌─────────────────────────────┼───────────────────────────────┐
│ Layer 2: Secret Encryption │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ vault_key (decrypted in memory) │ │
│ └─────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────┐ │ ┌────────────────────────────┐ │
│ │ API_KEY value │──┴──│ encrypted_value (in DB) │ │
│ └──────────────────┘ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘Layer 1 distributes the vault key to authorized users. Each user gets their own encrypted copy using their X25519 public key. No shared secret transmission—ECDH derives the encryption key.
Layer 2 encrypts individual secrets with the shared vault key. All team members with vault access can decrypt any secret.
Encryption Details
Vault Key Encryption (Layer 1)
The ephemeral keypair pattern encrypts the vault key for each recipient:
function encryptVaultKey(vaultKey: Buffer, recipientPubKey: string): EncryptedVaultKey {
// 1. Generate ephemeral X25519 keypair
const { publicKey, privateKey } = generateKeyPairSync('x25519', {
publicKeyEncoding: { type: 'spki', format: 'der' },
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
});
// 2. ECDH: ephemeral private + recipient public → shared secret
const sharedSecret = diffieHellman({ privateKey, publicKey: recipientPubKey });
// 3. HKDF-SHA256: shared secret → encryption key
const encKey = hkdfSync('sha256', sharedSecret, Buffer.alloc(0), 'noorm-vault-key', 32);
// 4. AES-256-GCM encryption
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', encKey, iv);
const ciphertext = Buffer.concat([cipher.update(vaultKey), cipher.final()]);
const authTag = cipher.getAuthTag();
return { ephemeralPubKey, iv, authTag, ciphertext };
}The stored payload contains:
ephemeralPubKey— Sender's ephemeral public key (for ECDH on decrypt)iv— 16-byte initialization vectorauthTag— 16-byte GCM authentication tagciphertext— Encrypted vault key
Decryption reverses the process: ECDH with the ephemeral public key and user's private key, same HKDF derivation, then AES-GCM decrypt.
Secret Encryption (Layer 2)
Individual secrets use straightforward AES-256-GCM:
function encryptSecret(value: string, vaultKey: Buffer) {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', vaultKey, iv);
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return { iv, authTag, ciphertext };
}Stored as JSON in the encrypted_value column. Each secret has its own random IV.
Database Schema
Two database changes support the vault:
__noorm_vault__ Table
CREATE TABLE __noorm_vault__ (
id INT PRIMARY KEY,
secret_key VARCHAR(255) NOT NULL UNIQUE,
encrypted_value TEXT NOT NULL,
set_by VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);The encrypted_value column stores JSON: { iv, authTag, ciphertext }.
__noorm_identities__ Extension
ALTER TABLE __noorm_identities__
ADD COLUMN encrypted_vault_key TEXT;When this column is NULL, the user hasn't received vault access yet. After propagation, it contains their encrypted copy of the vault key.
Secret Resolution
Templates reference secrets via secrets.KEY_NAME. The resolution order:
- Config-specific local secret — User's override for this config
- Global local secret — User's shared secret across configs
- Vault secret — Team-shared from database
async function resolveSecret(
stateManager: StateManager,
configName: string,
secretKey: string,
db?: Kysely<NoormDatabase>,
vaultKey?: Buffer,
): Promise<string | null> {
// 1. Config-specific local
const configSecret = stateManager.getSecret(configName, secretKey);
if (configSecret) return configSecret;
// 2. Global local
const globalSecret = stateManager.getGlobalSecret(secretKey);
if (globalSecret) return globalSecret;
// 3. Vault
if (db && vaultKey) {
return getVaultSecret(db, vaultKey, secretKey);
}
return null;
}For template rendering, buildSecretsContext() merges all three layers with proper priority:
async function buildSecretsContext(
stateManager: StateManager,
configName: string,
db?: Kysely<NoormDatabase>,
vaultKey?: Buffer,
): Promise<Record<string, string>> {
const secrets: Record<string, string> = {};
// 1. Start with vault (lowest priority)
if (db && vaultKey) {
const vaultSecrets = await getAllVaultSecrets(db, vaultKey);
for (const [key, secret] of Object.entries(vaultSecrets)) {
secrets[key] = secret.value;
}
}
// 2. Override with global local
for (const key of stateManager.listGlobalSecrets()) {
const value = stateManager.getGlobalSecret(key);
if (value) secrets[key] = value;
}
// 3. Override with config-specific (highest priority)
for (const key of stateManager.listSecrets(configName)) {
const value = stateManager.getSecret(configName, key);
if (value) secrets[key] = value;
}
return secrets;
}Core Operations
Initialization
The first user to initialize creates the vault key:
async function initializeVault(
db: Kysely<NoormDatabase>,
identityHash: string,
publicKey: string,
): Promise<[Buffer | null, Error | null]> {
// Check if already initialized
const existing = await db
.selectFrom('__noorm_identities__')
.select('encrypted_vault_key')
.where('encrypted_vault_key', 'is not', null)
.limit(1)
.executeTakeFirst();
if (existing?.encrypted_vault_key) {
return [null, new Error('Vault already initialized')];
}
// Generate new vault key
const vaultKey = randomBytes(32);
// Encrypt for initializer
const encrypted = encryptVaultKey(vaultKey, publicKey);
// Store
await db
.updateTable('__noorm_identities__')
.set({ encrypted_vault_key: JSON.stringify(encrypted) })
.where('identity_hash', '=', identityHash)
.execute();
observer.emit('vault:initialized', { identityHash });
return [vaultKey, null];
}Propagation
Users with vault access can propagate to pending users:
async function propagateVaultKey(
db: Kysely<NoormDatabase>,
vaultKey: Buffer,
): Promise<VaultPropagationResult> {
// Find users without access
const users = await db
.selectFrom('__noorm_identities__')
.select(['identity_hash', 'public_key', 'email'])
.where('encrypted_vault_key', 'is', null)
.execute();
const propagatedTo: string[] = [];
for (const user of users) {
// Encrypt vault key for this user's public key
const encrypted = encryptVaultKey(vaultKey, user.public_key);
await db
.updateTable('__noorm_identities__')
.set({ encrypted_vault_key: JSON.stringify(encrypted) })
.where('identity_hash', '=', user.identity_hash)
.execute();
propagatedTo.push(user.identity_hash);
observer.emit('vault:propagated', {
toIdentityHash: user.identity_hash,
toEmail: user.email,
});
}
return { propagatedTo, alreadyHadAccess: totalWithAccess - propagatedTo.length };
}Cross-Config Copy
Copy secrets between database configs:
async function copyVaultSecrets(
sourceConfig: Config,
destConfig: Config,
keys: string[] | 'all',
identityHash: string,
privateKey: string,
publicKey: string,
options: { force?: boolean },
): Promise<[VaultCopyResult | null, Error | null]> {
return withDualConnection({ sourceConfig, destConfig }, async (ctx) => {
// Get source vault key
const sourceVaultKey = await getVaultKey(ctx.source.db, identityHash, privateKey);
if (!sourceVaultKey) throw new Error('No vault access on source');
// Initialize or access destination vault
const destStatus = await getVaultStatus(ctx.destination.db, identityHash);
let destVaultKey: Buffer;
if (!destStatus.isInitialized) {
const [newKey] = await initializeVault(ctx.destination.db, identityHash, publicKey);
destVaultKey = newKey!;
} else {
destVaultKey = await getVaultKey(ctx.destination.db, identityHash, privateKey);
}
// Copy secrets
const allSourceSecrets = await getAllVaultSecrets(ctx.source.db, sourceVaultKey);
const result: VaultCopyResult = { copied: [], skipped: [], errors: [] };
for (const [key, secret] of Object.entries(allSourceSecrets)) {
if (keys !== 'all' && !keys.includes(key)) continue;
const exists = await vaultSecretExists(ctx.destination.db, key);
if (exists && !options.force) {
result.skipped.push(key);
continue;
}
await setVaultSecret(ctx.destination.db, destVaultKey, key, secret.value, `copied from ${sourceConfig.name}`);
result.copied.push(key);
}
return result;
});
}CLI Commands
vault init
Initialize the vault for the active config's database:
noorm vault initCreates a new vault key and stores it encrypted for your identity. Fails if vault already initialized.
vault set
Store a secret in the vault:
noorm vault set API_KEY "sk-live-..."
noorm vault set DB_PASSWORD "secret123"Upserts—creates new or updates existing. Records set_by for audit trail.
vault list
List all vault secrets:
noorm vault listShows keys, who set them, and timestamps. Values stay hidden.
vault rm
Delete a vault secret:
noorm vault rm OLD_API_KEYvault propagate
Grant vault access to pending team members:
noorm vault propagateEncrypts the vault key for each user's public key.
vault cp
Copy secrets between configs:
noorm vault cp API_KEY staging production # Copy one secret
noorm vault cp staging production # Copy all secrets
noorm vault cp --force staging production # Overwrite existing
noorm vault cp --dry-run staging production # Preview onlyObserver Events
Vault operations emit events for monitoring:
// Vault lifecycle
observer.on('vault:initialized', ({ identityHash }) => { ... });
observer.on('vault:propagated', ({ toIdentityHash, toEmail }) => { ... });
// Secret operations
observer.on('vault:secret:created', ({ key, setBy }) => { ... });
observer.on('vault:secret:updated', ({ key, setBy }) => { ... });
observer.on('vault:secret:deleted', ({ key }) => { ... });
// Copy operations
observer.on('vault:copy:starting', ({ source, destination, keys }) => { ... });
observer.on('vault:copy:completed', ({ source, destination, copied, skipped, errors }) => { ... });Types
interface EncryptedVaultKey {
ephemeralPubKey: string; // X25519 public key (hex)
iv: string; // Initialization vector (hex)
authTag: string; // GCM authentication tag (hex)
ciphertext: string; // Encrypted vault key (hex)
}
interface EncryptedSecret {
iv: string;
authTag: string;
ciphertext: string;
}
interface VaultSecret {
key: string;
value: string;
setBy: string;
createdAt: Date;
updatedAt: Date;
}
interface VaultStatus {
isInitialized: boolean;
hasAccess: boolean;
secretCount: number;
usersWithAccess: number;
usersWithoutAccess: number;
}
interface VaultCopyResult {
copied: string[];
skipped: string[];
errors: Array<{ key: string; error: string }>;
}
interface VaultPropagationResult {
propagatedTo: string[];
alreadyHadAccess: number;
}Security Properties
- End-to-end encryption — Secrets encrypted before transmission, decrypted only in memory
- Zero-knowledge database — Database sees only ciphertext; no plaintext ever stored
- Forward secrecy per user — Each user's vault key copy uses unique ephemeral keys
- Authenticated encryption — AES-GCM detects tampering
- No shared secrets in transit — ECDH derives keys without transmitting them
Limitations
- All-or-nothing access — Users with vault access can read all secrets
- No key rotation — Removing a user requires generating a new vault key (not yet implemented)
- Lost keys — If a user loses their private key, they must re-register identity
- Single vault per database — No per-team or per-project subdivision
