companion-client-encryptor.mjs
168 lines 6.4 KB
Raw
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