Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions src/utils/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,47 @@ const randomHexPrefix = (): string =>
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')

// Buffer does not exist in a browser, and this module is reachable from the
// package root, so the encoding uses the platform globals instead. TextEncoder
// and TextDecoder carry the UTF-8 step: btoa is latin1, so encoding a password
// through it directly corrupts anything non-ASCII. The binary string is built
// in chunks because spreading a whole byte array into String.fromCharCode
// overflows the call stack on long input.
const CHUNK_SIZE = 0x8000

const toBase64 = (bytes: Uint8Array): string => {
let binary = ''

for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + CHUNK_SIZE)
)
}

return btoa(binary)
}

const fromBase64 = (str: string): string => {
// Buffer ignores whitespace when decoding base64 and atob does not, so a
// wrapped or padded stored value still decodes.
const binary = atob(str.replace(/\s/g, ''))

return new TextDecoder().decode(
Uint8Array.from(binary, (char) => char.charCodeAt(0))
)
}

export const encodeToBase64 = (str: string) => {
const encodedPassword = Buffer.from(randomHexPrefix() + str).toString(
'base64'
const encodedPassword = toBase64(
new TextEncoder().encode(randomHexPrefix() + str)
)
return PREFIX + encodedPassword
}

export const decodeFromBase64 = (str: string) => {
if (str.startsWith(PREFIX)) {
str = str.replace(/^{sasjs_encoded}/, '')
const decodedPassword = Buffer.from(str, 'base64').toString()
const decodedPassword = fromBase64(str)
return decodedPassword.substring(200)
}
return str
Expand Down
36 changes: 36 additions & 0 deletions src/utils/spec/base64.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { encodeToBase64, decodeFromBase64 } from '../base64'

describe('base64', () => {
const originalPassword = 'hello-world'
const realBuffer = global.Buffer

afterEach(() => {
global.Buffer = realBuffer
})

it('should return a base64 encoded string with prefix', () => {
expect(encodeToBase64(originalPassword)).toMatch(/^{sasjs_encoded}/)
})
Expand Down Expand Up @@ -34,4 +42,32 @@ describe('base64', () => {
'SabirByd3VQOWJSRzZkRUpqSnBuMnprcU5ONzlHcUNYMXpVQUtWYzFKRWF0MVlSWFBzRzdHRjJUM1I1VW8yRWRpUW05dVh2MHp5MGJ5WWVqeEQwbVJtQjZzWGZTY3dQSlJBMkJWQ3NhWndReFdGTlNIZVVNVmtha1NuQVpoUHgyWTczMEJiNFRINVNhQ0dXczI0ODNHM1VQdFZMWDJDb1VaTDRkYm1BMVVmU21VYVgzc3VHR2VaejE4UUZCNjZrZTIxUG9OSTBDd2FoZWxsby13b3JsZA=='
expect(decodeFromBase64(rawPassword)).toEqual(rawPassword)
})

it('should round-trip a non-ASCII password', () => {
// btoa is latin1, so a naive swap to the platform APIs corrupts this. The
// password is stored, so a round trip has to survive the UTF-8 step.
const password = 'passwoerd-日本語-ünïcode'

expect(decodeFromBase64(encodeToBase64(password))).toEqual(password)
})

it('should round-trip without a Buffer global', () => {
// The browser case: Buffer does not exist, and this module is reachable
// from the package root, so no polyfill can be assumed.
// @ts-expect-error - simulating a browser, which has no Buffer global
delete global.Buffer

const password = 'passwoerd-日本語'

expect(decodeFromBase64(encodeToBase64(password))).toEqual(password)
})

it('should reference no Buffer in the module code', () => {
const source = readFileSync(join(__dirname, '..', 'base64.ts'), 'utf8')
const code = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')

expect(code).not.toMatch(/\bBuffer\b/)
})
})
Loading