11import {
22 createCipheriv ,
33 createDecipheriv ,
4+ createHash ,
45 createSecretKey ,
56 generateKeySync ,
7+ hkdfSync ,
68 randomBytes ,
9+ type DecipherGCM ,
710 type KeyObject ,
811} from "node:crypto" ;
912import { authenticationFailed , CryptoError , throwIfAborted } from "./errors.js" ;
1013import type { DataKeyContext , DataKeyProvider , GeneratedDataKey , WrappedDataKey } from "./types.js" ;
1114
15+ /** Salt-derived, one-use AES-256-GCM key wrapping used for new local key-ring writes. */
16+ export const AES_GCM_HKDF_SHA256_KEY_WRAP = "NESTM-A256GCM-HKDF-SHA256-SALT256-V2" ;
17+ /** @deprecated Read-only compatibility for envelopes written before AES_GCM_HKDF_SHA256_KEY_WRAP. */
1218export const AES_GCM_KEY_WRAP = "A256GCMKW" ;
13- const WRAP_VERSION = 1 ;
14- const WRAPPED_LENGTH = 1 + 12 + 32 + 16 ;
19+
20+ const SALTED_WRAP_VERSION = 2 ;
21+ const SALT_BYTES = 32 ;
22+ const DATA_KEY_BYTES = 32 ;
23+ const TAG_BYTES = 16 ;
24+ const SALTED_WRAPPED_LENGTH = 1 + SALT_BYTES + DATA_KEY_BYTES + TAG_BYTES ;
25+ const FIXED_WRAP_IV = new Uint8Array ( 12 ) ;
26+ const KEY_DERIVATION_INFO = "nestm:aes-key-ring:a256gcm-hkdf-sha256-salt256:v2\0" ;
27+ const KEY_REFERENCE_CONTEXT = "nestm:aes-key-ring:key-reference:v2\0" ;
28+ const WRAPPING_CONTEXT = "nestm:aes-key-ring:wrapping-context:v2\0" ;
29+ const WRAP_AUTHENTICATED_DATA = Buffer . concat ( [
30+ Buffer . from ( "nestm:aes-key-ring:wrapped-data-key:v2\0" , "utf8" ) ,
31+ Buffer . of ( SALTED_WRAP_VERSION ) ,
32+ ] ) ;
33+ const LEGACY_WRAP_VERSION = 1 ;
34+ const LEGACY_WRAPPED_LENGTH = 1 + 12 + 32 + 16 ;
1535
1636export interface AesKeyRingProviderOptions {
1737 readonly activeKeyId : string ;
@@ -42,6 +62,57 @@ function validateKeyId(keyId: string): void {
4262 }
4363}
4464
65+ function framedDigest ( domain : string , value : Uint8Array ) : Buffer {
66+ const length = Buffer . alloc ( 8 ) ;
67+ length . writeBigUInt64BE ( BigInt ( value . byteLength ) ) ;
68+ try {
69+ return createHash ( "sha256" ) . update ( domain , "utf8" ) . update ( length ) . update ( value ) . digest ( ) ;
70+ } finally {
71+ length . fill ( 0 ) ;
72+ }
73+ }
74+
75+ function deriveOneUseWrappingKey (
76+ kek : KeyObject ,
77+ salt : Uint8Array ,
78+ keyReference : string ,
79+ wrappingContext : Uint8Array ,
80+ ) : Buffer {
81+ const reference = Buffer . from ( keyReference , "utf8" ) ;
82+ const referenceDigest = framedDigest ( KEY_REFERENCE_CONTEXT , reference ) ;
83+ const contextDigest = framedDigest ( WRAPPING_CONTEXT , wrappingContext ) ;
84+ const info = Buffer . concat ( [
85+ Buffer . from ( KEY_DERIVATION_INFO , "utf8" ) ,
86+ Buffer . of ( SALTED_WRAP_VERSION ) ,
87+ referenceDigest ,
88+ contextDigest ,
89+ ] ) ;
90+ try {
91+ return Buffer . from ( hkdfSync ( "sha256" , kek , salt , info , DATA_KEY_BYTES ) ) ;
92+ } finally {
93+ reference . fill ( 0 ) ;
94+ referenceDigest . fill ( 0 ) ;
95+ contextDigest . fill ( 0 ) ;
96+ info . fill ( 0 ) ;
97+ }
98+ }
99+
100+ function decryptDataKey ( decipher : DecipherGCM , ciphertext : Uint8Array ) : KeyObject {
101+ const first = decipher . update ( ciphertext ) ;
102+ let last : Buffer | undefined ;
103+ let raw : Buffer | undefined ;
104+ try {
105+ last = decipher . final ( ) ;
106+ raw = Buffer . concat ( [ first , last ] ) ;
107+ if ( raw . byteLength !== DATA_KEY_BYTES ) throw authenticationFailed ( ) ;
108+ return createSecretKey ( raw ) ;
109+ } finally {
110+ first . fill ( 0 ) ;
111+ last ?. fill ( 0 ) ;
112+ raw ?. fill ( 0 ) ;
113+ }
114+ }
115+
45116export class AesKeyRingProvider implements DataKeyProvider {
46117 readonly #activeKeyId: string ;
47118 readonly #keys: ReadonlyMap < string , KeyObject > ;
@@ -63,69 +134,114 @@ export class AesKeyRingProvider implements DataKeyProvider {
63134 async generateDataKey ( context : DataKeyContext ) : Promise < GeneratedDataKey > {
64135 throwIfAborted ( context . signal ) ;
65136 const plaintextKey = generateKeySync ( "aes" , { length : 256 } ) ;
66- const wrappedKey = this . #wrap( plaintextKey , this . #keys. get ( this . #activeKeyId) ! , context ) ;
137+ const wrappedKey = this . #wrap(
138+ plaintextKey ,
139+ this . #keys. get ( this . #activeKeyId) ! ,
140+ this . #activeKeyId,
141+ context ,
142+ ) ;
67143 throwIfAborted ( context . signal ) ;
68144 return Object . freeze ( {
69145 plaintextKey,
70146 wrappedKey,
71147 keyReference : this . #activeKeyId,
72- wrappingAlgorithm : AES_GCM_KEY_WRAP ,
148+ wrappingAlgorithm : AES_GCM_HKDF_SHA256_KEY_WRAP ,
73149 } ) ;
74150 }
75151
76152 async unwrapDataKey ( dataKey : WrappedDataKey , context : DataKeyContext ) : Promise < KeyObject > {
77153 throwIfAborted ( context . signal ) ;
78- if ( dataKey . wrappingAlgorithm !== AES_GCM_KEY_WRAP ) {
154+ if (
155+ dataKey . wrappingAlgorithm !== AES_GCM_HKDF_SHA256_KEY_WRAP &&
156+ dataKey . wrappingAlgorithm !== AES_GCM_KEY_WRAP
157+ ) {
79158 throw new CryptoError ( "INVALID_KEY" , "The wrapped-key algorithm is unsupported." ) ;
80159 }
81160 const kek = this . #keys. get ( dataKey . keyReference ) ;
82161 if ( ! kek ) throw new CryptoError ( "KEY_NOT_FOUND" , "The wrapping key was not found." ) ;
83- if (
84- dataKey . wrappedKey . byteLength !== WRAPPED_LENGTH ||
85- dataKey . wrappedKey [ 0 ] !== WRAP_VERSION
86- ) {
162+ if ( dataKey . wrappingAlgorithm === AES_GCM_HKDF_SHA256_KEY_WRAP ) {
163+ return this . #unwrapSalted( dataKey . wrappedKey , kek , dataKey . keyReference , context ) ;
164+ }
165+ return this . #unwrapLegacy( dataKey . wrappedKey , kek , context ) ;
166+ }
167+
168+ #unwrapSalted(
169+ wrappedKey : Uint8Array ,
170+ kek : KeyObject ,
171+ keyReference : string ,
172+ context : DataKeyContext ,
173+ ) : KeyObject {
174+ if ( wrappedKey . byteLength !== SALTED_WRAPPED_LENGTH || wrappedKey [ 0 ] !== SALTED_WRAP_VERSION ) {
87175 throw authenticationFailed ( ) ;
88176 }
177+ const salt = wrappedKey . subarray ( 1 , 1 + SALT_BYTES ) ;
178+ const ciphertext = wrappedKey . subarray ( 1 + SALT_BYTES , 1 + SALT_BYTES + DATA_KEY_BYTES ) ;
179+ const tag = wrappedKey . subarray ( 1 + SALT_BYTES + DATA_KEY_BYTES ) ;
180+ let wrappingKey : Buffer | undefined ;
89181 try {
90- const nonce = dataKey . wrappedKey . subarray ( 1 , 13 ) ;
91- const ciphertext = dataKey . wrappedKey . subarray ( 13 , 45 ) ;
92- const tag = dataKey . wrappedKey . subarray ( 45 ) ;
182+ wrappingKey = deriveOneUseWrappingKey ( kek , salt , keyReference , context . wrappingContext ) ;
183+ const decipher = createDecipheriv ( "aes-256-gcm" , wrappingKey , FIXED_WRAP_IV , {
184+ authTagLength : TAG_BYTES ,
185+ } ) ;
186+ decipher . setAAD ( WRAP_AUTHENTICATED_DATA , { plaintextLength : DATA_KEY_BYTES } ) ;
187+ decipher . setAuthTag ( tag ) ;
188+ return decryptDataKey ( decipher , ciphertext ) ;
189+ } catch ( error : unknown ) {
190+ if ( error instanceof CryptoError ) throw error ;
191+ throw authenticationFailed ( { cause : error } ) ;
192+ } finally {
193+ wrappingKey ?. fill ( 0 ) ;
194+ }
195+ }
196+
197+ #unwrapLegacy( wrappedKey : Uint8Array , kek : KeyObject , context : DataKeyContext ) : KeyObject {
198+ if ( wrappedKey . byteLength !== LEGACY_WRAPPED_LENGTH || wrappedKey [ 0 ] !== LEGACY_WRAP_VERSION ) {
199+ throw authenticationFailed ( ) ;
200+ }
201+ try {
202+ const nonce = wrappedKey . subarray ( 1 , 13 ) ;
203+ const ciphertext = wrappedKey . subarray ( 13 , 45 ) ;
204+ const tag = wrappedKey . subarray ( 45 ) ;
93205 const decipher = createDecipheriv ( "aes-256-gcm" , kek , nonce , { authTagLength : 16 } ) ;
94206 decipher . setAAD ( context . wrappingContext , { plaintextLength : 32 } ) ;
95207 decipher . setAuthTag ( tag ) ;
96- const first = decipher . update ( ciphertext ) ;
97- let last : Buffer | undefined ;
98- let raw : Buffer | undefined ;
99- try {
100- last = decipher . final ( ) ;
101- raw = Buffer . concat ( [ first , last ] ) ;
102- if ( raw . byteLength !== 32 ) throw authenticationFailed ( ) ;
103- return createSecretKey ( raw ) ;
104- } finally {
105- first . fill ( 0 ) ;
106- last ?. fill ( 0 ) ;
107- raw ?. fill ( 0 ) ;
108- }
208+ return decryptDataKey ( decipher , ciphertext ) ;
109209 } catch ( error : unknown ) {
110210 if ( error instanceof CryptoError ) throw error ;
111211 throw authenticationFailed ( { cause : error } ) ;
112212 }
113213 }
114214
115- #wrap( key : KeyObject , kek : KeyObject , context : DataKeyContext ) : Uint8Array {
116- const nonce = randomBytes ( 12 ) ;
215+ #wrap( key : KeyObject , kek : KeyObject , keyReference : string , context : DataKeyContext ) : Uint8Array {
216+ const salt = randomBytes ( SALT_BYTES ) ;
117217 const raw = key . export ( ) ;
218+ let wrappingKey : Buffer | undefined ;
219+ let ciphertext : Buffer | undefined ;
220+ let tag : Buffer | undefined ;
118221 try {
119- const cipher = createCipheriv ( "aes-256-gcm" , kek , nonce , { authTagLength : 16 } ) ;
120- cipher . setAAD ( context . wrappingContext , { plaintextLength : raw . byteLength } ) ;
121- const ciphertext = Buffer . concat ( [ cipher . update ( raw ) , cipher . final ( ) ] ) ;
122- const output = new Uint8Array ( WRAPPED_LENGTH ) ;
123- output [ 0 ] = WRAP_VERSION ;
124- output . set ( nonce , 1 ) ;
125- output . set ( ciphertext , 13 ) ;
126- output . set ( cipher . getAuthTag ( ) , 45 ) ;
222+ wrappingKey = deriveOneUseWrappingKey ( kek , salt , keyReference , context . wrappingContext ) ;
223+ const cipher = createCipheriv ( "aes-256-gcm" , wrappingKey , FIXED_WRAP_IV , {
224+ authTagLength : TAG_BYTES ,
225+ } ) ;
226+ cipher . setAAD ( WRAP_AUTHENTICATED_DATA , { plaintextLength : DATA_KEY_BYTES } ) ;
227+ ciphertext = Buffer . concat ( [ cipher . update ( raw ) , cipher . final ( ) ] ) ;
228+ if ( ciphertext . byteLength !== DATA_KEY_BYTES ) {
229+ throw new CryptoError ( "CIPHER_FAILURE" , "Key wrapping produced an invalid result." ) ;
230+ }
231+ tag = cipher . getAuthTag ( ) ;
232+ const output = new Uint8Array ( SALTED_WRAPPED_LENGTH ) ;
233+ output [ 0 ] = SALTED_WRAP_VERSION ;
234+ output . set ( salt , 1 ) ;
235+ output . set ( ciphertext , 1 + SALT_BYTES ) ;
236+ output . set ( tag , 1 + SALT_BYTES + DATA_KEY_BYTES ) ;
127237 return output ;
238+ } catch ( error : unknown ) {
239+ if ( error instanceof CryptoError ) throw error ;
240+ throw new CryptoError ( "CIPHER_FAILURE" , "Key wrapping failed." , { cause : error } ) ;
128241 } finally {
242+ wrappingKey ?. fill ( 0 ) ;
243+ ciphertext ?. fill ( 0 ) ;
244+ tag ?. fill ( 0 ) ;
129245 raw . fill ( 0 ) ;
130246 }
131247 }
0 commit comments