// ==================================
// 간단한 jwt 설명 데모
// ==================================
const {
createHash,
createHmac,
} = require('node:crypto');
const secret = 'Trixie_is_the_best_pony'
function getHeader(algorithm) {
return `{
"alg" : "${algorithm}",
"typ" : "JWT"
}`;
}
const payload = `{
"message" : "hi"
}`
function fuckedJWT() {
let signature = secret + getHeader('none') + '.' + payload;
return getHeader('none') + '.' + payload + '.' + signature;
}
function badInvalidJWT() {
let signature = secret + toUrlBase64(getHeader('none')) + '.' + toUrlBase64(payload);
return toUrlBase64(getHeader('none')) + '.' + toUrlBase64(payload) + '.' + toUrlBase64(signature);
}
// jwt sign을 아예 안할거며는 서명도 하지 말라고 합니다.
function badJWT() {
return toUrlBase64(getHeader('none')) + '.' + toUrlBase64(payload) + '.'
}
// sha256를 JWT에서 쓰지는 않습니다.
// sha256가 왜 않좋은지는 좀있다 설명하겠습니다.
function slightlyBetterButInvalidJWT() {
let signature = secret + toUrlBase64(getHeader('S256')) + '.' + toUrlBase64(payload);
signature = createHash('sha256')
.update(signature)
.digest('base64url')
return toUrlBase64(getHeader('S256')) + '.' + toUrlBase64(payload) + '.' + signature;
}
function goodJWT() {
let signature = toUrlBase64(getHeader('HS256')) + '.' + toUrlBase64(payload);
signature = createHmac('sha256', secret)
.update(signature)
.digest('base64url')
return toUrlBase64(getHeader('HS256')) + '.' + toUrlBase64(payload) + '.' + signature;
}
console.log("\nfuckedJWT: ")
printOneLine(fuckedJWT());
console.log("\nbadInvalidJWT: ")
printOneLine(badInvalidJWT());
console.log("\nbadJWT: ")
printOneLine(badJWT());
console.log("\nslightlyBetterButInvalidJWT: ")
printOneLine(slightlyBetterButInvalidJWT());
console.log("\ngoodJWT: ")
printOneLine(goodJWT());
// ====================
// length attack
// ====================
// 왜 sha256를 안쓰는지 설명하겠습니다.
// 저희 sha256을 쓸때 간단하게 secret 과 message를 갖다 붙여
// signature를 만들고 있습니다.
//
// 하지만 이런 문제가 발생합니다.
console.log()
console.log('============ CONCAT ============')
console.log('should not be '
+ (sha256String('secret' + '69') == sha256String('secret6' + '9')))
// 하지만 더 심각한 문제는 length attack에 있습니다.
//
// 여기다 sha256를 구현할 시간도 실력도 없으므로 매우 간단한 예시를
// 들겠습니다.
// 1 PADING
//
// sha256 는 512 bit chunk 단위로 데이터를 처리합니다.
//
// 하지만 세상의 모든 데이터가 512로 나누어 떨어지진 않을거기 때문에
// 데이터가 오면 먼저 데이터가 512bit가 되도록 0을 마구 집어넣습니다.
//
// 그 담에, 데이터가 512로 나누어 떨어지기 직전에 64bit 형태로 원본
// 데이터의 크기를 저장합니다.
//
//
// 2 PROCESSING
//
// sha256은 32bit 숫자 8개를 가지고 있습니다.
//
// 그리고 각 512bit chunk가 숫자 8개를 좀 복작한 수학 연산을 이용해
// 바꿉니다.
//
// 그리고 이렇게 바뀐 숫자 8개를 이어 붙여서 돌려줍니다.
//
// 이 수학 연산은 복잡해서 어떤 데이터가 이 숫자 8개를 생성해내는지 알아내는 것은
// 해커 입장에서 매우 어렵습니다.
//
// 하지만 그렇다고 너무 복잡하진 않아서 데이터를 검증한다고 서버가 버벅대지는 않습니다.
//
// 이샹 sha256 야매 설명이었습니다.
// 하지만 sha256은 큰문제가 있습니다!
//
// 예를들어 sha256(secret + message) 의 경우
//
// 해커 아저씨 아줌마가
//
// 1. secret의 길이
// 2. message
// 3. sha256(secret + message)의 결과를 알경우
//
// !!! secret이 뭔지 모르는 상태에서 !!!
// sha256(secret + message + attack)이 무슨 값을 내뿜을지 예측할수 있습니다!
//
// 원리는 간단합니다.
//
// secret + message를 sha256이 padding 하기 전에 해커가 미리 padding하는 것입니다!
//
// 이때 secret은 몰라도 됩니다!
//
// 해커 입장에서 중요한건 sha256이 가지고 있는 8개의 숫자이고 이는 할때마다 똑같은 값에서 시작하거든요.
//
// 그리고 이미 해커는 8개의 숫자가 뭔지 알고 있으니까 해커는 padding한 secret + message는 넘기고
// 시작 8개읜 숫자르 이미 알고 있는 숫자고 설정한뒤 자신의 attack만 process하면 됩니다!
//
// 이를 시연 위해 매우 간단한 sha 알고리즘을 짰습니다.
//
// 진짜 sha256과 다른 점은 512bit 단위가 아닌
// 8bit 단위로 chunk를 나눈다는 것하고
//
// processing 이 매애애애애우 단순하다는 점과
//
// 숫자 8개 대신에 숫자 하나만 쓴단든 점입니다.
{
console.log()
console.log('============ LENGTH ATTACK ============')
function pad(data) {
data.push(1)
while (data.length % 8 != 0) {
data.push(0);
}
}
function pushLength(data, length) {
for (let i=7; i>=0; i--) {
data.push((length & 1 << i) > 0 ? 1 : 0)
}
}
function mySha(msg) {
msg = msg.slice();
// padding
let msgLength = msg.length;
pad(msg)
pushLength(msg, msgLength)
// process
let hash = 0;
for (let i=0; i<msg.length; i++) {
hash += msg[i];
}
console.log(`padded msg : ${msg}`)
console.log(`hash : ${hash}`)
return hash;
}
let secret = [1, 1]
let msg = [1, 0, 1]
mySha(secret.concat(msg));
function myShaAttack(
secretKeyLength,
msg,
attackMsg,
knownHash,
) {
msg = msg.slice();
attackMsg = attackMsg.slice();
// 일단 알고 있는 secretKeyLength 만큼 0을 앞에 넣습니다
for (let i=0; i<secretKeyLength; i++) {
msg.unshift(0)
}
// 전과 같이 padding을 합니다
let msgLength = msg.length;
pad(msg)
pushLength(msg, msgLength)
let glue = msg.slice(msgLength)
// 공격 시작!
// process를 시작할 배열을 저장합니다
let processStart = msg.length;
// 저희의 공격 msg를 넣습니다.
msg = msg.concat(attackMsg);
// 길이를 새로 설정 합니다.
msgLength = msg.length;
pad(msg)
pushLength(msg, msgLength)
// process
// 0 부터가 아닌 우리가 알고 있는 hash로 부터 시작합니다
let hash = knownHash;
for (let i=processStart; i<msg.length; i++) {
hash += msg[i];
}
console.log(`padded msg : ${msg}`)
console.log(`hash : ${hash}`)
return {
hash : hash,
glue: glue
};
}
let attack = [1, 1, 1]
const attackInfo = myShaAttack(
2,
msg,
attack,
7
);
attack = [msg, attackInfo.glue, attack].flat()
console.log(attackInfo.hash == mySha(secret.concat(attack)))
}
// 이러한 문제 때문에 jwt 쓸때 sha256을 가지고
//
// sha256(secret+message) 이런식으로 signature를 만들면 안됩니다.
//
// 그런 문제를 해결할려고 hmacsha256이라는 거를 사람들이 만들어 놨으니까
// 그거 쓰면 됩니다.
// ====================
// UTIL
// ====================
function printOneLine(toPrint) {
console.log(toPrint.replaceAll('\n', '\\n'))
}
// base64 encoding은 binary data를 문자로 변환하는 함수
// base64 url encoding은 base64 랑 거의 똑같은데
// 웹상에서 쓰기 좋으라고 좀 바꾼거.
function toUrlBase64(str) {
const bytes = new TextEncoder().encode(str);
let binString = '';
for (const b of bytes) {
binString += String.fromCodePoint(b)
}
const base64Encoded = btoa(binString);
// url에서 쓰기에는 안전하지 못할수 있으니
// + 는 -
// / 는 _
// 그리고 base64 encoding은 문자열 길이가 4의 배수로 만들려고 = 붙임
// 이거 제거
return base64Encoded
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '');
}
function fromUrlBase64(str) {
str = str
.replaceAll('-', '+')
.replaceAll('_', '/');
while(str.length % 4 != 0) {
str += '='
}
const binString = atob(str);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0));
return new TextDecoder().decode(bytes);
}
function sha256String(str) {
return createHash('sha256').update(str).digest('base64');
}