release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
packages/shade-storage-encrypted/src/crypto/key-manager.ts
Normal file
132
packages/shade-storage-encrypted/src/crypto/key-manager.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* KeyManager — owns the masterKey lifecycle for at-rest encryption.
|
||||
*
|
||||
* Three sources are supported:
|
||||
* 1. passphrase — scrypt-derived from a developer-supplied secret
|
||||
* 2. keychain — fetched from OS keychain via @shade/keychain (optional dep)
|
||||
* 3. injected — caller supplies the 32-byte raw key directly
|
||||
*
|
||||
* The KeyManager pre-derives storageKey at construction time and caches the
|
||||
* per-(table, column) field keys. masterKey is zeroized after storageKey
|
||||
* derivation to limit residency.
|
||||
*/
|
||||
|
||||
import { deriveFieldKey, deriveMasterKey, deriveStorageKey, type ScryptParams, DEFAULT_SCRYPT } from './kdf.js';
|
||||
|
||||
export type KeySource =
|
||||
| {
|
||||
kind: 'passphrase';
|
||||
passphrase: string;
|
||||
/** Stable 16+ byte salt persisted alongside the DB. */
|
||||
salt: Uint8Array;
|
||||
params?: ScryptParams;
|
||||
}
|
||||
| {
|
||||
kind: 'keychain';
|
||||
/** Service identifier (e.g. "shade.storage"). */
|
||||
service: string;
|
||||
/** Account / key name within the keychain. */
|
||||
account: string;
|
||||
/** If true, generate + store a new 32-byte key when one is absent. */
|
||||
createIfMissing?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'injected';
|
||||
/** Raw 32-byte master key. */
|
||||
key: Uint8Array;
|
||||
};
|
||||
|
||||
/** Pluggable keychain backend. Implementations live in @shade/keychain. */
|
||||
export interface KeychainBackend {
|
||||
get(service: string, account: string): Promise<Uint8Array | null>;
|
||||
set(service: string, account: string, value: Uint8Array): Promise<void>;
|
||||
delete(service: string, account: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface KeyManagerOptions {
|
||||
/** Required when KeySource.kind === 'keychain'. */
|
||||
keychain?: KeychainBackend;
|
||||
}
|
||||
|
||||
export class KeyManager {
|
||||
private readonly storageKey: Uint8Array;
|
||||
private readonly fieldKeyCache = new Map<string, Uint8Array>();
|
||||
|
||||
private constructor(storageKey: Uint8Array) {
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a KeyManager from any supported source. May call the OS keychain
|
||||
* (async) or run scrypt (slow on cold start).
|
||||
*/
|
||||
static async open(source: KeySource, opts: KeyManagerOptions = {}): Promise<KeyManager> {
|
||||
const masterKey = await resolveMasterKey(source, opts);
|
||||
try {
|
||||
const storageKey = deriveStorageKey(masterKey);
|
||||
return new KeyManager(storageKey);
|
||||
} finally {
|
||||
// Zeroize whichever buffer we can — never the caller's own buffer
|
||||
// when source.kind === 'injected', since the caller may still hold it.
|
||||
if (source.kind !== 'injected') masterKey.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up (and cache) the AEAD key for a given table+column. */
|
||||
fieldKey(table: string, column: string): Uint8Array {
|
||||
const cacheKey = `${table}\x1f${column}`;
|
||||
let k = this.fieldKeyCache.get(cacheKey);
|
||||
if (!k) {
|
||||
k = deriveFieldKey(this.storageKey, table, column);
|
||||
this.fieldKeyCache.set(cacheKey, k);
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
/** Sanity helper for verifying that a passphrase decrypts the active DB. */
|
||||
storageKeyFingerprint(): Uint8Array {
|
||||
// Deterministic 8-byte tag for "is this the same masterKey?" without
|
||||
// exposing the storageKey itself. Tag-only — not a MAC.
|
||||
const tag = new Uint8Array(8);
|
||||
for (let i = 0; i < this.storageKey.length; i++) {
|
||||
tag[i % 8]! ^= this.storageKey[i]!;
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** Forget all derived keys — call on shutdown. */
|
||||
destroy(): void {
|
||||
this.storageKey.fill(0);
|
||||
for (const v of this.fieldKeyCache.values()) v.fill(0);
|
||||
this.fieldKeyCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMasterKey(source: KeySource, opts: KeyManagerOptions): Promise<Uint8Array> {
|
||||
switch (source.kind) {
|
||||
case 'passphrase':
|
||||
return deriveMasterKey(source.passphrase, source.salt, source.params ?? DEFAULT_SCRYPT);
|
||||
|
||||
case 'injected':
|
||||
if (source.key.length !== 32) throw new Error('injected key must be exactly 32 bytes');
|
||||
return new Uint8Array(source.key); // copy, in case caller mutates
|
||||
|
||||
case 'keychain': {
|
||||
if (!opts.keychain) {
|
||||
throw new Error('keychain source requires opts.keychain (install @shade/keychain)');
|
||||
}
|
||||
const existing = await opts.keychain.get(source.service, source.account);
|
||||
if (existing) {
|
||||
if (existing.length !== 32) throw new Error('keychain returned a non-32-byte key');
|
||||
return existing;
|
||||
}
|
||||
if (!source.createIfMissing) {
|
||||
throw new Error(`no key in keychain for ${source.service}/${source.account}`);
|
||||
}
|
||||
const fresh = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(fresh);
|
||||
await opts.keychain.set(source.service, source.account, fresh);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user