11import crypto from 'crypto' ;
22import fs from 'fs' ;
3+ import { z } from 'zod' ;
34import { env } from './env.server.js' ;
45import { Token } from '@sourcebot/schemas/v3/shared.type' ;
56import { SecretManagerServiceClient } from "@google-cloud/secret-manager" ;
@@ -113,21 +114,30 @@ export const getTokenFromConfig = async (token: Token): Promise<string> => {
113114const oauthAlgorithm = 'aes-256-gcm' ;
114115const oauthIvLength = 16 ;
115116const oauthSaltLength = 64 ;
116- const oauthTagLength = 16 ;
117- const oauthTagPosition = oauthSaltLength + oauthIvLength ;
118- const oauthEncryptedPosition = oauthTagPosition + oauthTagLength ;
119- const minEncryptedLength = 128 ; // Minimum base64-encoded length for encrypted tokens
117+
118+ /**
119+ * Schema for encrypted OAuth token structure.
120+ * Stored as base64-encoded JSON in the database.
121+ */
122+ const encryptedOAuthTokenSchema = z . object ( {
123+ v : z . literal ( 1 ) , // Version for future format changes
124+ salt : z . string ( ) , // hex-encoded salt for key derivation
125+ iv : z . string ( ) , // hex-encoded initialization vector
126+ tag : z . string ( ) , // hex-encoded auth tag
127+ data : z . string ( ) , // hex-encoded encrypted data
128+ } ) ;
129+
130+ type EncryptedOAuthToken = z . infer < typeof encryptedOAuthTokenSchema > ;
120131
121132function deriveOAuthKey ( authSecret : string , salt : Buffer ) : Buffer {
122133 return crypto . pbkdf2Sync ( authSecret , salt , 100000 , 32 , 'sha256' ) ;
123134}
124135
125136function isOAuthTokenEncrypted ( token : string ) : boolean {
126- if ( token . length < minEncryptedLength ) return false ;
127-
128137 try {
129- const decoded = Buffer . from ( token , 'base64' ) ;
130- return decoded . length >= ( oauthSaltLength + oauthIvLength + oauthTagLength ) ;
138+ const decoded = Buffer . from ( token , 'base64' ) . toString ( 'utf8' ) ;
139+ const parsed = JSON . parse ( decoded ) ;
140+ return encryptedOAuthTokenSchema . safeParse ( parsed ) . success ;
131141 } catch {
132142 return false ;
133143 }
@@ -136,40 +146,59 @@ function isOAuthTokenEncrypted(token: string): boolean {
136146/**
137147 * Encrypts OAuth token using AUTH_SECRET. Idempotent - returns token unchanged if already encrypted.
138148 */
139- export function encryptOAuthToken ( text : string | null | undefined , authSecret : string ) : string | null {
140- if ( ! text || ! authSecret ) return null ;
141- if ( isOAuthTokenEncrypted ( text ) ) return text ;
142-
149+ export function encryptOAuthToken ( text : string | null | undefined ) : string | undefined {
150+ if ( ! text ) {
151+ return undefined ;
152+ }
153+
154+ if ( isOAuthTokenEncrypted ( text ) ) {
155+ return text ;
156+ }
157+
143158 const iv = crypto . randomBytes ( oauthIvLength ) ;
144159 const salt = crypto . randomBytes ( oauthSaltLength ) ;
145- const key = deriveOAuthKey ( authSecret , salt ) ;
146-
160+ const key = deriveOAuthKey ( env . AUTH_SECRET , salt ) ;
161+
147162 const cipher = crypto . createCipheriv ( oauthAlgorithm , key , iv ) ;
148163 const encrypted = Buffer . concat ( [ cipher . update ( text , 'utf8' ) , cipher . final ( ) ] ) ;
149164 const tag = cipher . getAuthTag ( ) ;
150-
151- return Buffer . concat ( [ salt , iv , tag , encrypted ] ) . toString ( 'base64' ) ;
165+
166+ const tokenData : EncryptedOAuthToken = {
167+ v : 1 ,
168+ salt : salt . toString ( 'hex' ) ,
169+ iv : iv . toString ( 'hex' ) ,
170+ tag : tag . toString ( 'hex' ) ,
171+ data : encrypted . toString ( 'hex' ) ,
172+ } ;
173+
174+ return Buffer . from ( JSON . stringify ( tokenData ) ) . toString ( 'base64' ) ;
152175}
153176
154177/**
155178 * Decrypts OAuth token using AUTH_SECRET. Returns plaintext tokens unchanged during migration.
156179 */
157- export function decryptOAuthToken ( encryptedText : string | null | undefined , authSecret : string ) : string | null {
158- if ( ! encryptedText || ! authSecret ) return null ;
159- if ( ! isOAuthTokenEncrypted ( encryptedText ) ) return encryptedText ;
160-
180+ export function decryptOAuthToken ( encryptedText : string | null | undefined ) : string | undefined {
181+ if ( ! encryptedText ) {
182+ return undefined ;
183+ }
184+
185+ if ( ! isOAuthTokenEncrypted ( encryptedText ) ) {
186+ return encryptedText ;
187+ }
188+
161189 try {
162- const data = Buffer . from ( encryptedText , 'base64' ) ;
163-
164- const salt = data . subarray ( 0 , oauthSaltLength ) ;
165- const iv = data . subarray ( oauthSaltLength , oauthTagPosition ) ;
166- const tag = data . subarray ( oauthTagPosition , oauthEncryptedPosition ) ;
167- const encrypted = data . subarray ( oauthEncryptedPosition ) ;
168-
169- const key = deriveOAuthKey ( authSecret , salt ) ;
190+ const decoded = Buffer . from ( encryptedText , 'base64' ) . toString ( 'utf8' ) ;
191+ const tokenData = encryptedOAuthTokenSchema . parse ( JSON . parse ( decoded ) ) ;
192+
193+ const salt = Buffer . from ( tokenData . salt , 'hex' ) ;
194+ const iv = Buffer . from ( tokenData . iv , 'hex' ) ;
195+ const tag = Buffer . from ( tokenData . tag , 'hex' ) ;
196+ const encrypted = Buffer . from ( tokenData . data , 'hex' ) ;
197+
198+ const key = deriveOAuthKey ( env . AUTH_SECRET , salt ) ;
170199 const decipher = crypto . createDecipheriv ( oauthAlgorithm , key , iv ) ;
171200 decipher . setAuthTag ( tag ) ;
172-
201+
173202 return decipher . update ( encrypted , undefined , 'utf8' ) + decipher . final ( 'utf8' ) ;
174203 } catch {
175204 // Decryption failed - likely a plaintext token, return as-is
0 commit comments