companion-client-encryptor.mjs
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠ breaking
3 hours ago
| 1 | /** |
| 2 | * Phase 6 — ClientEncryptor hook interface (D6.4). |
| 3 | * |
| 4 | * Defines the seam for client-side encryption of privacy-max derived artifacts. |
| 5 | * The actual ZK key hierarchy (Argon2id → IK → DEK-vault/DEK-memory, hybrid |
| 6 | * X25519+ML-KEM-768 envelope encryption) is NOT implemented here — it is a hard |
| 7 | * prerequisite owned by the ZK tier's gate (brief §9.4, gate §13.3). |
| 8 | * |
| 9 | * This module provides: |
| 10 | * 1. The interface contract (JSDoc typedef + ENCRYPTOR_SCOPES + reason codes). |
| 11 | * 2. The default "unavailable" implementation so privacy_max ALWAYS fails closed |
| 12 | * until the ZK tier ships. |
| 13 | * 3. A `createClientEncryptor` factory that wraps any concrete implementation |
| 14 | * and enforces the contract surface (no decrypt(), no key material in output). |
| 15 | * |
| 16 | * RULES (hard constraints from D6.4): |
| 17 | * - No decrypt() is part of this interface — reads/decryption are the ZK tier's concern. |
| 18 | * - No plaintext fallback, ever (D6.4.3 / Rule #1). |
| 19 | * Unavailable encryptor → fail closed (drop or local-only), never downgrade to plaintext. |
| 20 | * - The encryptor lives ONLY in the authority group (D6.4.5). |
| 21 | * The runtime/inference group must NEVER import or hold this module. |
| 22 | * - Algorithm agility: the `alg` field + `schema_version` (D6.2) record the scheme |
| 23 | * so the ZK tier can introduce hybrid wrapping without breaking stored artifacts (D6.4.4). |
| 24 | */ |
| 25 | |
| 26 | /** |
| 27 | * @typedef {Object} EncryptResult |
| 28 | * @property {Uint8Array} ciphertext - Encrypted bytes. Never plaintext. |
| 29 | * @property {string} wrappedDekRef - Opaque reference to the wrapped DEK (for crypto-shred, D6.5.3). |
| 30 | * Never the key material itself. |
| 31 | * @property {string} alg - Encryption algorithm identifier (D6.4.4 algorithm agility). |
| 32 | */ |
| 33 | |
| 34 | /** |
| 35 | * @typedef {Object} ClientEncryptorLike |
| 36 | * @property {(tier: string, scope: string) => boolean} isAvailable |
| 37 | * Returns true ONLY when a user-held key for this vault/memory scope is unlocked |
| 38 | * in this process (client-side). Always false at convenience tier and whenever the |
| 39 | * ZK hierarchy is absent or locked. Fail-closed default: false. |
| 40 | * Must never throw — any error returns false. |
| 41 | * @property {(plaintextBytes: Uint8Array, opts: { scope: string, aad?: Uint8Array }) => EncryptResult} encrypt |
| 42 | * Envelope-encrypt under the user-held DEK for `scope` (vault | memory). |
| 43 | * Returns { ciphertext, wrappedDekRef, alg }; NEVER returns key material. |
| 44 | * Throws on any key/encryption error — the caller MUST fail closed (D6.4.3). |
| 45 | * Never called at convenience tier. |
| 46 | */ |
| 47 | |
| 48 | /** |
| 49 | * Valid scope values for the ClientEncryptor (D6.4.1). |
| 50 | * 'vault' → note frontmatter / vector store artifacts (ai_summary, embedding) |
| 51 | * 'memory' → insight / discovery_facet artifacts (memory partition) |
| 52 | * @readonly |
| 53 | */ |
| 54 | export const ENCRYPTOR_SCOPES = Object.freeze({ |
| 55 | VAULT: 'vault', |
| 56 | MEMORY: 'memory', |
| 57 | }); |
| 58 | |
| 59 | /** |
| 60 | * Fixed reason codes for ClientEncryptor failures. |
| 61 | * Callers surface these codes — never the underlying exception message. |
| 62 | * @readonly |
| 63 | */ |
| 64 | export const ENCRYPTOR_REASONS = Object.freeze({ |
| 65 | /** isAvailable returned false — ZK key hierarchy absent or locked. */ |
| 66 | UNAVAILABLE: 'encryptor_unavailable_no_user_held_key', |
| 67 | /** encrypt() threw or returned a malformed result. */ |
| 68 | ENCRYPT_FAILED: 'encryptor_encrypt_failed', |
| 69 | /** |
| 70 | * Sentinel: a code path attempted a plaintext fallback. This MUST never be reached |
| 71 | * in production code — its presence signals a logic error. |
| 72 | */ |
| 73 | PLAINTEXT_FALLBACK_FORBIDDEN: 'encryptor_plaintext_fallback_forbidden', |
| 74 | }); |
| 75 | |
| 76 | /** |
| 77 | * The default "unavailable" ClientEncryptor. |
| 78 | * |
| 79 | * isAvailable always returns false so privacy_max writes always fail closed |
| 80 | * until the ZK key hierarchy is implemented. This is the correct default — Phase 6 |
| 81 | * ships with this implementation and the privacy_max path refuses to persist. |
| 82 | * |
| 83 | * @type {ClientEncryptorLike} |
| 84 | */ |
| 85 | export const UNAVAILABLE_CLIENT_ENCRYPTOR = Object.freeze({ |
| 86 | /** |
| 87 | * Always returns false — ZK hierarchy not implemented yet. |
| 88 | * @param {string} _tier |
| 89 | * @param {string} _scope |
| 90 | * @returns {false} |
| 91 | */ |
| 92 | isAvailable(_tier, _scope) { |
| 93 | return false; |
| 94 | }, |
| 95 | |
| 96 | /** |
| 97 | * Always throws — no plaintext fallback, ever (D6.4.3 / Rule #1). |
| 98 | * @param {Uint8Array} _plaintextBytes |
| 99 | * @param {{ scope: string, aad?: Uint8Array }} _opts |
| 100 | * @returns {never} |
| 101 | */ |
| 102 | encrypt(_plaintextBytes, _opts) { |
| 103 | throw new Error(ENCRYPTOR_REASONS.UNAVAILABLE); |
| 104 | }, |
| 105 | }); |
| 106 | |
| 107 | /** |
| 108 | * Create a validated ClientEncryptor from a concrete implementation. |
| 109 | * |
| 110 | * Wraps the implementation to enforce the contract: |
| 111 | * - isAvailable must return boolean (exceptions → false, never throw to caller) |
| 112 | * - encrypt must return { ciphertext: Uint8Array, wrappedDekRef: string, alg: string } |
| 113 | * - No decrypt() is exposed in the returned encryptor |
| 114 | * |
| 115 | * @param {{ isAvailable: Function, encrypt: Function }} impl |
| 116 | * @returns {ClientEncryptorLike} |
| 117 | * @throws {TypeError} if impl does not provide isAvailable and encrypt. |
| 118 | */ |
| 119 | export function createClientEncryptor(impl) { |
| 120 | if ( |
| 121 | impl == null || |
| 122 | typeof impl.isAvailable !== 'function' || |
| 123 | typeof impl.encrypt !== 'function' |
| 124 | ) { |
| 125 | throw new TypeError( |
| 126 | 'ClientEncryptor implementation must provide isAvailable(tier, scope) and encrypt(plaintextBytes, opts).', |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | return Object.freeze({ |
| 131 | /** |
| 132 | * Delegates to impl.isAvailable; any exception → false (fail-closed). |
| 133 | * @param {string} tier |
| 134 | * @param {string} scope |
| 135 | * @returns {boolean} |
| 136 | */ |
| 137 | isAvailable(tier, scope) { |
| 138 | try { |
| 139 | return impl.isAvailable(tier, scope) === true; |
| 140 | } catch { |
| 141 | return false; |
| 142 | } |
| 143 | }, |
| 144 | |
| 145 | /** |
| 146 | * Delegates to impl.encrypt; validates the result shape before returning. |
| 147 | * Throws with ENCRYPTOR_REASONS.ENCRYPT_FAILED if the result is malformed. |
| 148 | * @param {Uint8Array} plaintextBytes |
| 149 | * @param {{ scope: string, aad?: Uint8Array }} opts |
| 150 | * @returns {EncryptResult} |
| 151 | */ |
| 152 | encrypt(plaintextBytes, opts) { |
| 153 | // No plaintext fallback (D6.4.3): if impl throws, we re-throw, caller must fail closed. |
| 154 | const result = impl.encrypt(plaintextBytes, opts); |
| 155 | if ( |
| 156 | result == null || |
| 157 | !(result.ciphertext instanceof Uint8Array) || |
| 158 | typeof result.wrappedDekRef !== 'string' || |
| 159 | !result.wrappedDekRef || |
| 160 | typeof result.alg !== 'string' || |
| 161 | !result.alg |
| 162 | ) { |
| 163 | throw new Error(ENCRYPTOR_REASONS.ENCRYPT_FAILED); |
| 164 | } |
| 165 | return result; |
| 166 | }, |
| 167 | }); |
| 168 | } |
File History
1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb
reconcile: import GitHub-direct RBAC/OAuth/companion and ho…
Human
minor
⚠
3 hours ago