/** * 빌더 자체 암호화 브라우저 모듈 — GWENC-v1 봉투·임시 키쌍 (BIZSOLUTION-45196 B-2, 상위 BIZSOLUTION-45193) * * login2(AuthSSLManager2 JSONP) 를 대체한다. 서버 짝은 lib/Builder.LocalCrypt.class.php 이고 * 봉투 형식·평문 스키마는 그 파일 머리말과 같다. 고객그룹웨어 GwLocalCrypt.js(BIZSOLUTION-45211) 와 같은 규격이다. * http 접속 화면에서만 쓴다 — https 접속은 평문 POST(TLS) 로 보낸다(분기는 B-3 공통 함수가 서버 값으로 정한다). * * 로드 순서 (같은 출처의 /cjs/ 에서) * * * 표시용(유형 B)은 /cjs/BuilderLocalCrypt.worker.js 를 worker 로 띄운다(이 파일 src 옆 경로). * * 봉투 GWENC-v1 * wrapped_key = RSA-OAEP(Kenc[32] || Kmac[32]) hash SHA-1, MGF1-SHA1, label 빈 값 (서버 OPENSSL_PKCS1_OAEP_PADDING) * iv = 16B, ciphertext = AES-256-CBC-PKCS7(Kenc, iv, UTF-8 JSON) * tag = HMAC-SHA256(Kmac, LP(version)||LP(kid)||LP(purpose)||LP(wrapped_key)||LP(iv)||LP(ciphertext)) * 전송형 = "GWENC1." + base64( 위 6필드 LP + LP(tag) ), LP(x) = 4바이트 big-endian 길이 + x * * 입력(유형 A) — 제출 버튼을 누른 직후 * 1. POST /chtml/builder_nonce.php (X-Builder-Nonce-Request: 1, purpose=) → nonce·kid·공개키·고객 ID * 응답이 없을 때(네트워크 오류·타임아웃)만 1회 다시 요청한다. 403·429·5xx 는 다시 요청하지 않는다 * 2. 평문 {"v","p":용도,"c":고객 ID,"n":nonce,"d":업무 데이터} 를 봉투로 감싼다 * 봉투 제출 결과(서버 검증 실패)는 원인을 알 수 없으므로 이 모듈은 다시 보내지 않는다 * * 표시용(유형 B) — 화면 로드와 동시에 prepareView() 로 worker 에서 RSA 2048 임시 키쌍 생성을 시작한다(화면당 1회) * openView() 가 SPKI 공개키·요청 ID 를 화면 엔드포인트로 POST 하고, 받은 봉투를 worker 안에서 * kid(내 키)·HMAC·용도·요청 ID 확인 뒤 복호한다. 개인키는 worker 밖으로 나오지 않고 사용 후 worker 를 끝낸다. * 보호 범위는 HTTPS 전환 전 수동 도청 완화(임시 보완통제)다. 결과는 textContent·value 로만 넣는다(innerHTML 금지) * * 난수는 crypto.getRandomValues(IE11 msCrypto) 만 쓴다. 없으면 암호화를 멈춘다(fail-closed). * forge 의 자체 엔트로피 수집(forge.random 의 Math.random 폴백)이 쓰이지 않도록 forge.random 의 * getBytes·getBytesSync 를 getRandomValues 직결 구현으로 바꾼다. forge.options.usePureJavaScript 는 건드리지 않는다. * RSA 는 OAEP 만 쓴다(PKCS#1 v1.5 금지). * * ES5 만 쓴다(IE11 포함 — Promise·화살표 함수·let/const 미사용). window 와 worker 양쪽에서 로드된다. * 화면 인코딩(EUC-KR)과 무관하게 동작하도록 문자열 리터럴은 ASCII(\u 이스케이프)만 쓴다. * * @encoding UTF-8 */ (function(root){ 'use strict'; var VERSION = 'GWENC-v1'; var WIRE_PREFIX = 'GWENC1.'; /* 입력(서버 MAX_WIRE_LEN·MAX_CT_LEN 과 같은 값) — 폼메일 본문·견적 메모 같은 긴 입력을 받는다 [BIZSOLUTION-45198] */ var MAX_WIRE_LEN = 98304; var MAX_CT_LEN = 65536; /* 표시용(서버 MAX_VIEW_CT_LEN) — 주문 상세 등 표시 데이터가 8KB 를 넘을 수 있다 */ var MAX_VIEW_WIRE_LEN = 1048576; var MAX_VIEW_CT_LEN = 524288; var KEY_LEN = 32; var IV_LEN = 16; var TAG_LEN = 32; var BLOCK_LEN = 16; var INPUT_RSA_BITS = 2048; var VIEW_RSA_BITS = 2048; var NONCE_TIMEOUT_MS = 10000; var VIEW_TIMEOUT_MS = 20000; var KEYGEN_TIMEOUT_MS = 60000; var PEM_RE = /^-----BEGIN PUBLIC KEY-----\r?\n([A-Za-z0-9+\/=\r\n]+)-----END PUBLIC KEY-----\r?\n?$/; var KID_RE = /^[a-z0-9-]{1,64}$/; var PURPOSE_RE = /^[a-z][a-z0-9_]{0,31}$/; /* 안내 문구는 \u 이스케이프로 둔다 — 서버가 JS 에 charset 을 붙이지 않아 화면 인코딩(EUC-KR 등)으로 읽혀도 깨지지 않게 "이 브라우저는 보안 입력을 지원하지 않습니다. 최신 브라우저로 다시 시도해 주세요." "보안 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." */ var MESSAGE_UNSUPPORTED = '\uc774 \ube0c\ub77c\uc6b0\uc800\ub294 \ubcf4\uc548 \uc785\ub825\uc744 \uc9c0\uc6d0\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \ucd5c\uc2e0 \ube0c\ub77c\uc6b0\uc800\ub85c \ub2e4\uc2dc \uc2dc\ub3c4\ud574 \uc8fc\uc138\uc694.'; var MESSAGE_RETRY = '\ubcf4\uc548 \ucc98\ub9ac \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc7a0\uc2dc \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574 \uc8fc\uc138\uc694.'; /* "\ubd88\ub7ec\uc624\ub294 \uc911" \u2014 \ud45c\uc2dc\uc6a9 \ud0a4 \uc0dd\uc131\u00b7\uc751\ub2f5 \ub300\uae30 \uc911 \uac1c\uc778\uc815\ubcf4 \uce78\uc5d0 \ub454\ub2e4 */ var MESSAGE_LOADING = '\ubd88\ub7ec\uc624\ub294 \uc911'; /** * 내부 오류 — code 는 분류용(화면 문구는 두 가지뿐) */ function CryptError(sCode) { this.code = sCode; this.message = (sCode === 'E_UNSUPPORTED' || sCode === 'E_RANDOM') ? MESSAGE_UNSUPPORTED : MESSAGE_RETRY; } function toError(e) { return (e instanceof CryptError) ? e : new CryptError('E_INTERNAL'); } /** * 문자열일 때만 정규식 검사(RegExp.test 는 undefined 를 "undefined" 로 바꿔 검사하므로) */ function matches(oRe, mValue) { return typeof mValue === 'string' && oRe.test(mValue) === true; } /* --------------------------------------------------------------------------------------------- * 난수·forge 준비 * ------------------------------------------------------------------------------------------- */ function getCrypto() { var oCrypto = root.crypto || root.msCrypto; if(!oCrypto || typeof oCrypto.getRandomValues !== 'function' || typeof Uint8Array === 'undefined'){ return null; } return oCrypto; } /** * CSPRNG 바이트(바이너리 문자열). getRandomValues 가 없거나 실패하면 예외 — 다른 원천으로 대신하지 않는다 */ function randomBytes(iLength) { var oCrypto = getCrypto(); if(oCrypto === null){ throw new CryptError('E_RANDOM'); } var sOut = ''; var iDone = 0; while(iDone < iLength){ var iChunk = Math.min(iLength - iDone, 65536); var aBuf = new Uint8Array(iChunk); try{ oCrypto.getRandomValues(aBuf); }catch(e){ throw new CryptError('E_RANDOM'); } for(var i = 0; i < iChunk; i++){ sOut += String.fromCharCode(aBuf[i]); } iDone += iChunk; } return sOut; } /** * forge 를 가져오고 forge.random 을 getRandomValues 직결로 바꾼다(한 번만) */ function getForge() { var oForge = root.forge; if(!oForge || !oForge.pki || !oForge.pki.rsa || !oForge.cipher || !oForge.hmac || !oForge.random || !oForge.asn1){ throw new CryptError('E_UNSUPPORTED'); } if(oForge.random.builderLocalCrypt !== true){ oForge.random.getBytesSync = function(iCount){ return randomBytes(iCount); }; oForge.random.getBytes = function(iCount, fnCallback){ if(typeof fnCallback === 'function'){ var sBytes; try{ sBytes = randomBytes(iCount); }catch(e){ fnCallback(e); return; } fnCallback(null, sBytes); return; } return randomBytes(iCount); }; oForge.random.builderLocalCrypt = true; } return oForge; } /* --------------------------------------------------------------------------------------------- * 봉투 코어 (window·worker 공통) * ------------------------------------------------------------------------------------------- */ /** * 길이 접두어 직렬화 — 4바이트 big-endian 길이 + 값 (서버 BuilderReplayMarker::serialize 와 같음) */ function serialize(aParts) { var sOut = ''; for(var i = 0; i < aParts.length; i++){ var iLen = aParts[i].length; sOut += String.fromCharCode((iLen >>> 24) & 255, (iLen >>> 16) & 255, (iLen >>> 8) & 255, iLen & 255) + aParts[i]; } return sOut; } /** * RSA-OAEP 매개변수 — 기본값에 기대지 않고 SHA-1·MGF1-SHA1·빈 label 을 명시한다 */ function oaepOptions(sSeed) { var oForge = getForge(); var oOptions = { md: oForge.md.sha1.create(), mgf1: {md: oForge.md.sha1.create()}, label: '' }; if(sSeed !== undefined){ oOptions.seed = sSeed; } return oOptions; } function hmacSha256(sKey, sData) { var oHmac = getForge().hmac.create(); oHmac.start('sha256', sKey); oHmac.update(sData); return oHmac.digest().getBytes(); } /** * 상수시간 비교(길이가 같을 때 내용에 따라 시간이 달라지지 않게) */ function equals(sA, sB) { if(typeof sA !== 'string' || typeof sB !== 'string' || sA.length !== sB.length){ return false; } var iDiff = 0; for(var i = 0; i < sA.length; i++){ iDiff |= sA.charCodeAt(i) ^ sB.charCodeAt(i); } return iDiff === 0; } /** * 봉투 생성. oMaterial 은 교차 검증 CLI 의 고정값 known-answer 용(keys 64B·iv 16B·seed 20B) — 화면 경로는 넘기지 않는다 * * @param {Object} oPublic forge RSA 공개키 * @param {String} sKid * @param {String} sPurpose * @param {String} sPlain 평문 바이트(바이너리 문자열, UTF-8) * @param {Object} [oMaterial] * @return {String} 전송형 */ function sealEnvelope(oPublic, sKid, sPurpose, sPlain, oMaterial) { var oForge = getForge(); if(matches(KID_RE, sKid) !== true || matches(PURPOSE_RE, sPurpose) !== true){ throw new CryptError('E_FORMAT'); } var sKeys = oMaterial ? oMaterial.keys : randomBytes(KEY_LEN * 2); var sIv = oMaterial ? oMaterial.iv : randomBytes(IV_LEN); var sSeed = oMaterial ? oMaterial.seed : randomBytes(20); if(sKeys.length !== KEY_LEN * 2 || sIv.length !== IV_LEN || sSeed.length !== 20){ throw new CryptError('E_FORMAT'); } var sWrapped = oPublic.encrypt(sKeys, 'RSA-OAEP', oaepOptions(sSeed)); var oCipher = oForge.cipher.createCipher('AES-CBC', sKeys.substr(0, KEY_LEN)); oCipher.start({iv: sIv}); oCipher.update(oForge.util.createBuffer(sPlain)); if(oCipher.finish() !== true){ throw new CryptError('E_INTERNAL'); } var sCt = oCipher.output.getBytes(); if(sCt.length > MAX_CT_LEN){ throw new CryptError('E_LEN'); } var sHead = serialize([VERSION, sKid, sPurpose, sWrapped, sIv, sCt]); var sTag = hmacSha256(sKeys.substr(KEY_LEN, KEY_LEN), sHead); var sWire = WIRE_PREFIX + oForge.util.encode64(sHead + serialize([sTag])); if(sWire.length > MAX_WIRE_LEN){ throw new CryptError('E_LEN'); } return sWire; } /** * 전송형 파싱 — 길이·형식·버전·kid·용도 (서버 parseWire 와 같은 제약) * bView 가 true 면 표시용 상한(서버 sealForBrowser 의 MAX_VIEW_CT_LEN)을 쓴다 */ function parseWire(sWire, bView) { var iMaxWire = (bView === true) ? MAX_VIEW_WIRE_LEN : MAX_WIRE_LEN; var iMaxCt = (bView === true) ? MAX_VIEW_CT_LEN : MAX_CT_LEN; if(typeof sWire !== 'string' || sWire.length > iMaxWire || sWire.length <= WIRE_PREFIX.length || sWire.substr(0, WIRE_PREFIX.length) !== WIRE_PREFIX){ throw new CryptError('E_FORMAT'); } var sB64 = sWire.substr(WIRE_PREFIX.length); if(/^[A-Za-z0-9+\/]+={0,2}$/.test(sB64) !== true || sB64.length % 4 !== 0){ throw new CryptError('E_FORMAT'); } var sBin = getForge().util.decode64(sB64); var aFields = []; var iPos = 0; for(var i = 0; i < 7; i++){ if(sBin.length - iPos < 4){ throw new CryptError('E_FORMAT'); } var iSize = ((sBin.charCodeAt(iPos) << 24) >>> 0) + (sBin.charCodeAt(iPos + 1) << 16) + (sBin.charCodeAt(iPos + 2) << 8) + sBin.charCodeAt(iPos + 3); iPos += 4; if(iSize < 1 || iSize > sBin.length - iPos){ throw new CryptError('E_FORMAT'); } aFields.push(sBin.substr(iPos, iSize)); iPos += iSize; } if(iPos !== sBin.length){ throw new CryptError('E_FORMAT'); } var oEnv = { version: aFields[0], kid: aFields[1], purpose: aFields[2], wrapped: aFields[3], iv: aFields[4], ct: aFields[5], tag: aFields[6], head: sBin.substr(0, sBin.length - (4 + aFields[6].length)) }; if(oEnv.version !== VERSION || matches(KID_RE, oEnv.kid) !== true || matches(PURPOSE_RE, oEnv.purpose) !== true || oEnv.iv.length !== IV_LEN || oEnv.tag.length !== TAG_LEN || oEnv.ct.length % BLOCK_LEN !== 0 || oEnv.ct.length > iMaxCt || oEnv.wrapped.length > 512){ throw new CryptError('E_FORMAT'); } return oEnv; } /** * RSA 복원 → HMAC 상수시간 검증 → (성공 시에만) AES 복호·PKCS7 * * @return {String} 평문 바이트 */ function openEnvelope(oPrivate, oEnv) { var oForge = getForge(); var sKeys; try{ sKeys = oPrivate.decrypt(oEnv.wrapped, 'RSA-OAEP', oaepOptions()); }catch(e){ throw new CryptError('E_RSA'); } if(typeof sKeys !== 'string' || sKeys.length !== KEY_LEN * 2){ throw new CryptError('E_RSA'); } if(equals(hmacSha256(sKeys.substr(KEY_LEN, KEY_LEN), oEnv.head), oEnv.tag) !== true){ throw new CryptError('E_MAC'); } var oDecipher = oForge.cipher.createDecipher('AES-CBC', sKeys.substr(0, KEY_LEN)); oDecipher.start({iv: oEnv.iv}); oDecipher.update(oForge.util.createBuffer(oEnv.ct)); if(oDecipher.finish() !== true){ throw new CryptError('E_PAD'); } return oDecipher.output.getBytes(); } /** * SPKI(SubjectPublicKeyInfo) DER */ function publicKeyToDer(oPublic) { var oForge = getForge(); return oForge.asn1.toDer(oForge.pki.publicKeyToAsn1(oPublic)).getBytes(); } function derToPem(sDer) { var sB64 = getForge().util.encode64(sDer); var aLines = []; for(var i = 0; i < sB64.length; i += 64){ aLines.push(sB64.substr(i, 64)); } return '-----BEGIN PUBLIC KEY-----\n' + aLines.join('\n') + '\n-----END PUBLIC KEY-----\n'; } /** * 표시용 봉투의 kid — 서버 sealForBrowser 가 'browser-' + sha256(SPKI DER) 앞 16자로 만든다 */ function browserKid(sDer) { var oMd = getForge().md.sha256.create(); oMd.update(sDer); return 'browser-' + oMd.digest().toHex().substr(0, 16); } /** * SPKI PEM → forge 공개키. RSA·허용 길이·공개 지수 65537 만 * * @param {String} sPem * @param {Array} aBits 허용 비트 수 * @return {Object} {key, der} */ function publicKeyFromPem(sPem, aBits) { var oForge = getForge(); var aMatch = (typeof sPem === 'string' && sPem.length <= 1024) ? PEM_RE.exec(sPem) : null; if(aMatch === null){ throw new CryptError('E_PUBKEY'); } var sB64 = aMatch[1].replace(/[\r\n]/g, ''); if(/^[A-Za-z0-9+\/]+={0,2}$/.test(sB64) !== true || sB64.length % 4 !== 0){ throw new CryptError('E_PUBKEY'); } var sDer = oForge.util.decode64(sB64); var oPublic; try{ oPublic = oForge.pki.publicKeyFromAsn1(oForge.asn1.fromDer(sDer)); }catch(e){ throw new CryptError('E_PUBKEY'); } var bBitsOk = false; for(var i = 0; i < aBits.length; i++){ if(oPublic.n.bitLength() === aBits[i]){ bBitsOk = true; } } if(bBitsOk !== true || oPublic.e.toString(16) !== '10001' || publicKeyToDer(oPublic) !== sDer){ throw new CryptError('E_PUBKEY'); } return {key: oPublic, der: sDer}; } /** * 임시 RSA 키쌍 생성(동기 — worker 안에서 부른다). 난수는 getRandomValues 직결 prng * * @return {Object} {privateKey, spki, kid} */ function generateViewKey(iBits) { var oForge = getForge(); if(iBits !== 2048 && iBits !== 3072){ throw new CryptError('E_FORMAT'); } randomBytes(1); var oPair = oForge.pki.rsa.generateKeyPair({ bits: iBits, e: 0x10001, prng: {getBytesSync: randomBytes} }); var sDer = publicKeyToDer(oPair.publicKey); return {privateKey: oPair.privateKey, spki: derToPem(sDer), kid: browserKid(sDer)}; } /** * 표시용 봉투 열기 — kid(내 키)·용도·요청 ID 바인딩 확인 후 d 반환 */ function openViewEnvelope(oPrivate, sMyKid, sWire, sPurpose, sRequestId) { var oEnv = parseWire(sWire, true); if(oEnv.kid !== sMyKid || oEnv.purpose !== sPurpose){ throw new CryptError('E_BIND'); } var oPayload; try{ oPayload = JSON.parse(getForge().util.decodeUtf8(openEnvelope(oPrivate, oEnv))); }catch(e){ throw (e instanceof CryptError) ? e : new CryptError('E_SCHEMA'); } if(oPayload === null || typeof oPayload !== 'object' || oPayload.v !== VERSION || oPayload.p !== sPurpose || oPayload.r !== sRequestId || typeof oPayload.iat !== 'number' || ('d' in oPayload) !== true){ throw new CryptError('E_SCHEMA'); } return oPayload.d; } /* --------------------------------------------------------------------------------------------- * 화면(window) 경로 * ------------------------------------------------------------------------------------------- */ /** * 같은 출처 경로만 허용 ("/..." 이고 "//" 아님) */ function isLocalPath(sUrl) { return typeof sUrl === 'string' && /^\/(?!\/)[^\s\\]*$/.test(sUrl); } function formEncode(oParams) { var aPairs = []; for(var sKey in oParams){ if(Object.prototype.hasOwnProperty.call(oParams, sKey) === true && oParams[sKey] !== undefined && oParams[sKey] !== null){ aPairs.push(encodeURIComponent(sKey) + '=' + encodeURIComponent(String(oParams[sKey]))); } } return aPairs.join('&'); } /** * POST 한 번. fnDone(iStatus, sText) — 응답이 없으면(네트워크 오류·타임아웃) iStatus 0 */ function postOnce(sUrl, sBody, oHeaders, iTimeout, fnDone) { var oXhr = new XMLHttpRequest(); var bDone = false; var finish = function(iStatus, sText){ if(bDone === true){ return; } bDone = true; fnDone(iStatus, sText); }; oXhr.open('POST', sUrl, true); oXhr.timeout = iTimeout; oXhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); for(var sName in oHeaders){ if(Object.prototype.hasOwnProperty.call(oHeaders, sName) === true){ oXhr.setRequestHeader(sName, oHeaders[sName]); } } oXhr.onreadystatechange = function(){ if(oXhr.readyState === 4){ finish(oXhr.status, oXhr.status === 0 ? '' : oXhr.responseText); } }; oXhr.ontimeout = function(){ finish(0, ''); }; oXhr.onerror = function(){ finish(0, ''); }; try{ oXhr.send(sBody); }catch(e){ finish(0, ''); } } /** * 응답 없음 — 0(네트워크 오류·타임아웃). IE 는 연결 끊김을 WinINet 오류 코드(12000~12999)로 줄 수 있다 */ function isNoResponse(iStatus) { return iStatus === 0 || (iStatus >= 12000 && iStatus <= 12999); } function parseJson(sText) { try{ return JSON.parse(sText); }catch(e){ return null; } } /** * nonce 발급 — 제출 버튼을 누른 직후 부른다 * 응답 없음(status 0)만 1회 다시 요청, 403·429·5xx·형식 오류는 다시 요청하지 않는다 * * @param {String} sPurpose 입력 용도(서버 허용목록) * @param {Function} fnCallback (error, {nonce, kid, version, customer, key}) */ function requestNonce(sPurpose, fnCallback) { if(matches(PURPOSE_RE, sPurpose) !== true || isLocalPath(BuilderLocalCrypt.nonceUrl) !== true){ fnCallback(new CryptError('E_FORMAT')); return; } var iAttempt = 0; var send = function(){ iAttempt++; postOnce(BuilderLocalCrypt.nonceUrl, formEncode({purpose: sPurpose}), {'X-Builder-Nonce-Request': '1'}, NONCE_TIMEOUT_MS, function(iStatus, sText){ if(isNoResponse(iStatus) === true){ if(iAttempt < 2){ send(); return; } fnCallback(new CryptError('E_NETWORK')); return; } if(iStatus !== 200){ fnCallback(new CryptError(iStatus === 429 ? 'E_RATE' : 'E_DENIED')); return; } var oJson = parseJson(sText); if(oJson === null || oJson.result !== 'ok' || typeof oJson.nonce !== 'string' || /^[0-9a-f]{32,64}$/.test(oJson.nonce) !== true || typeof oJson.kid !== 'string' || matches(KID_RE, oJson.kid) !== true || oJson.v !== VERSION || typeof oJson.c !== 'string' || /^[a-z0-9_]+$/.test(oJson.c) !== true){ fnCallback(new CryptError('E_FORMAT')); return; } var oKey; try{ oKey = publicKeyFromPem(oJson.pub, [INPUT_RSA_BITS]); }catch(e){ fnCallback(toError(e)); return; } fnCallback(null, {nonce: oJson.nonce, kid: oJson.kid, version: oJson.v, customer: oJson.c, key: oKey.key}); }); }; send(); } /** * 입력 봉투 만들기 (nonce 발급 포함) * * @param {Object} oOpts {purpose: 입력 용도, data: 업무 데이터(JSON 가능 값)} * @param {Function} fnCallback (error, wire) */ function encryptInput(oOpts, fnCallback) { if(isSupported(false) !== true){ fnCallback(new CryptError('E_UNSUPPORTED')); return; } var sPurpose = oOpts ? oOpts.purpose : ''; requestNonce(sPurpose, function(oError, oInfo){ if(oError){ fnCallback(oError); return; } var sWire; try{ var sJson = JSON.stringify({v: VERSION, p: sPurpose, c: oInfo.customer, n: oInfo.nonce, d: oOpts.data}); sWire = sealEnvelope(oInfo.key, oInfo.kid, sPurpose, getForge().util.encodeUtf8(sJson)); }catch(e){ fnCallback(toError(e)); return; } fnCallback(null, sWire); }); } /* 표시용 worker 상태 (화면당 하나) */ var oView = null; function workerUrl() { if(typeof BuilderLocalCrypt.workerUrl === 'string'){ return BuilderLocalCrypt.workerUrl; } var sUrl = '/cjs/BuilderLocalCrypt.worker.js'; if(root.document && root.document.getElementsByTagName){ var aScripts = root.document.getElementsByTagName('script'); for(var i = 0; i < aScripts.length; i++){ var sSrc = aScripts[i].getAttribute('src') || ''; if(/(^|\/)BuilderLocalCrypt\.js(\?[^#]*)?$/.test(sSrc) === true){ sUrl = sSrc.replace(/BuilderLocalCrypt\.js/, 'BuilderLocalCrypt.worker.js'); } } } return sUrl; } /** * 표시용 상태에 남은 대기·호출을 모두 실패로 끝낸다(콜백은 상태를 정리한 뒤 부른다 — 콜백 안에서 disposeView 가 불려도 안전) */ function failPending(oTarget, sCode) { if(oTarget.error === null){ oTarget.error = new CryptError(sCode); } var oError = oTarget.error; var aWaiting = oTarget.waiting; var aCalls = []; for(var sId in oTarget.calls){ if(Object.prototype.hasOwnProperty.call(oTarget.calls, sId) === true){ aCalls.push(oTarget.calls[sId]); } } oTarget.waiting = []; oTarget.calls = {}; for(var i = 0; i < aWaiting.length; i++){ aWaiting[i](oError); } for(var j = 0; j < aCalls.length; j++){ aCalls[j]({ok: false, code: oError.code}); } } /** * 키·worker 를 더 쓸 수 없게 된 경우 — 남은 대기·호출을 실패로 끝내고, 진행 중 openView 가 없으면 바로 정리한다 * (prepareView 만 부른 화면에서도 worker 와 키가 남지 않게) */ function breakView(oTarget, sCode) { failPending(oTarget, sCode); if(oView === oTarget && oTarget.active === 0){ disposeView(); } } /** * 표시용 임시 키쌍 생성 시작 — 화면 로드와 함께 부른다(이미 시작했으면 아무것도 안 함) * * @return {Boolean} 시작(또는 진행 중)이면 true */ function prepareView() { if(oView !== null){ return oView.error === null; } oView = {worker: null, ready: false, error: null, spki: '', kid: '', waiting: [], calls: {}, seq: 0, active: 0, keep: false, timer: null}; if(isSupported(true) !== true){ oView.error = new CryptError('E_UNSUPPORTED'); return false; } var oCurrent = oView; try{ oCurrent.worker = new root.Worker(workerUrl()); }catch(e){ oCurrent.error = new CryptError('E_UNSUPPORTED'); return false; } oCurrent.worker.onmessage = function(oEvent){ var oMsg = oEvent.data || {}; if(oView !== oCurrent){ return; } if(oMsg.id === 0){ root.clearTimeout(oCurrent.timer); if(oCurrent.error !== null){ /* 이미 실패(타임아웃 등)로 정한 뒤 늦게 온 응답 — 쓰지 않는다 */ return; } if(oMsg.ok === true && typeof oMsg.spki === 'string' && matches(KID_RE, oMsg.kid) === true){ oCurrent.ready = true; oCurrent.spki = oMsg.spki; oCurrent.kid = oMsg.kid; var aWaiting = oCurrent.waiting; oCurrent.waiting = []; for(var i = 0; i < aWaiting.length; i++){ aWaiting[i](null); } }else{ breakView(oCurrent, oMsg.code === 'E_RANDOM' ? 'E_RANDOM' : 'E_KEYGEN'); } return; } var fnCall = oCurrent.calls[oMsg.id]; if(fnCall){ delete oCurrent.calls[oMsg.id]; fnCall(oMsg); } }; oCurrent.worker.onerror = function(){ if(oView === oCurrent){ root.clearTimeout(oCurrent.timer); breakView(oCurrent, oCurrent.ready === true ? 'E_OPEN' : 'E_KEYGEN'); } }; /* 저사양 PC 실측 최대 8.2초(설계문서 v5) — 그 몇 배를 넘기면 멈춘 것으로 본다 */ oCurrent.timer = root.setTimeout(function(){ if(oView === oCurrent && oCurrent.ready !== true){ breakView(oCurrent, 'E_KEYGEN'); } }, KEYGEN_TIMEOUT_MS); oCurrent.worker.postMessage({id: 0, cmd: 'generate', bits: VIEW_RSA_BITS}); return true; } function whenViewReady(oTarget, fnCallback) { if(oTarget.error !== null){ fnCallback(oTarget.error); }else if(oTarget.ready === true){ fnCallback(null); }else{ oTarget.waiting.push(fnCallback); } } /** * 표시용 키쌍·worker 정리(개인키 참조 해제). 남은 대기·호출은 실패로 끝낸다 */ function disposeView() { if(oView === null){ return; } var oTarget = oView; oView = null; root.clearTimeout(oTarget.timer); if(oTarget.worker !== null){ try{ oTarget.worker.postMessage({id: -1, cmd: 'dispose'}); oTarget.worker.terminate(); }catch(e){ /* 이미 종료됨 */ } } failPending(oTarget, 'E_DISPOSED'); } /** * 표시 데이터 받기 — 화면 엔드포인트에 gwenc_pub(SPKI)·gwenc_rid(요청 ID)를 POST 하고 * 응답 {"result":"ok","wire":"GWENC1..."} 을 worker 에서 연다. * 진행 중인 openView 가 모두 끝난 뒤, 키 오류가 났거나 keep 을 요청한 호출이 없으면 worker 를 끝낸다 * * @param {Object} oOpts {url: 같은 출처 경로, params: 추가 POST 값, purpose: 화면 용도, keep: true 면 키 유지, loading: fn(bool)} * @param {Function} fnCallback (error, data) — 정확히 한 번 불린다 */ function openView(oOpts, fnCallback) { oOpts = oOpts || {}; var fnLoading = (typeof oOpts.loading === 'function') ? oOpts.loading : function(){}; if(isLocalPath(oOpts.url) !== true || matches(PURPOSE_RE, oOpts.purpose) !== true){ fnCallback(new CryptError('E_FORMAT')); return; } fnLoading(true); prepareView(); var oCurrent = oView; var bDone = false; oCurrent.active++; if(oOpts.keep === true){ oCurrent.keep = true; } var done = function(oError, mData){ if(bDone === true){ return; } bDone = true; oCurrent.active--; if(oView === oCurrent && oCurrent.active === 0 && (oCurrent.error !== null || oCurrent.keep !== true)){ disposeView(); } fnLoading(false); fnCallback(oError || null, mData); }; whenViewReady(oCurrent, function(oError){ if(oError){ done(oError); return; } var sRequestId; try{ sRequestId = getForge().util.bytesToHex(randomBytes(16)); }catch(e){ done(toError(e)); return; } var oParams = {}; for(var sKey in (oOpts.params || {})){ if(Object.prototype.hasOwnProperty.call(oOpts.params, sKey) === true){ oParams[sKey] = oOpts.params[sKey]; } } oParams.gwenc_pub = oCurrent.spki; oParams.gwenc_rid = sRequestId; postOnce(oOpts.url, formEncode(oParams), {}, VIEW_TIMEOUT_MS, function(iStatus, sText){ var oJson = (iStatus === 200) ? parseJson(sText) : null; if(oJson === null || oJson.result !== 'ok' || typeof oJson.wire !== 'string'){ done(new CryptError(isNoResponse(iStatus) === true ? 'E_NETWORK' : 'E_DENIED')); return; } if(oView !== oCurrent || oCurrent.error !== null){ done(oCurrent.error || new CryptError('E_DISPOSED')); return; } oCurrent.seq++; var iId = oCurrent.seq; var iTimer = root.setTimeout(function(){ if(oCurrent.calls[iId]){ delete oCurrent.calls[iId]; /* worker 가 멈춘 것으로 보고 키를 버린다(keep 화면에서도 진행 중 호출이 끝나면 정리) */ if(oCurrent.error === null){ oCurrent.error = new CryptError('E_TIMEOUT'); } done(oCurrent.error); } }, VIEW_TIMEOUT_MS); oCurrent.calls[iId] = function(oMsg){ root.clearTimeout(iTimer); if(oMsg.ok !== true){ done(new CryptError(oMsg.code === 'E_RANDOM' ? 'E_RANDOM' : 'E_OPEN')); return; } done(null, oMsg.data); }; oCurrent.worker.postMessage({id: iId, cmd: 'open', wire: oJson.wire, purpose: oOpts.purpose, rid: sRequestId}); }); }); } /* --------------------------------------------------------------------------------------------- * AuthSSLManager.weave() 호환 * ------------------------------------------------------------------------------------------- */ /** * 입력 요소 값 — AuthSSLManager.getValue·checkbox 와 같은 규칙. 요소가 없으면 null(건너뜀) */ function readField(sId) { var oDoc = root.document; var oEl = oDoc.getElementById(sId); if(oEl === null){ var aByName = oDoc.getElementsByName(sId); if(aByName.length === 0){ return null; } oEl = aByName[0]; } var sType = oEl.getAttribute('type'); if(sType === 'radio' || sType === 'checkbox'){ var aGroup = oDoc.getElementsByName(sId); if(aGroup.length === 0){ return oEl.checked === true ? oEl.value : ''; } var sChecked = ''; for(var i = 0; i < aGroup.length; i++){ if(aGroup[i].checked === true){ sChecked = aGroup[i].value; } } return sChecked; } return oEl.value; } function resolveCallback(mCallback) { if(typeof mCallback === 'function'){ return mCallback; } if(typeof mCallback === 'string' && /^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(mCallback) === true){ var oTarget = root; var aPath = mCallback.split('.'); for(var i = 0; i < aPath.length && oTarget; i++){ oTarget = oTarget[aPath[i]]; } if(typeof oTarget === 'function'){ return oTarget; } } return null; } /** * AuthSSLManager.weave 와 같은 콜백 형태 * encrypt : {auth_mode:'encrypt', aEleId:[요소 id...], purpose, auth_callbackName} * → 콜백(전송형 문자열) * decryptClient : {auth_mode:'decryptClient', url, params?, purpose, keep?, loading?, auth_callbackName} * → 콜백(표시 데이터 객체 — login2 와 달리 serialize 문자열이 아니다) * 실패는 "ERROR: 안내 문구" 문자열로 콜백(isError 로 판별). 자동 재시도는 하지 않는다 */ function weave(oParam) { oParam = oParam || {}; var fnCallback = resolveCallback(oParam.auth_callbackName); if(fnCallback === null){ return; } var fail = function(oError){ fnCallback('ERROR: ' + toError(oError).message); }; if(oParam.auth_mode === 'encrypt'){ var oData = {}; try{ var aIds = oParam.aEleId || []; for(var i = 0; i < aIds.length; i++){ var mValue = readField(aIds[i]); if(mValue === null){ continue; } oData[aIds[i]] = mValue; } }catch(e){ fail(e); return; } encryptInput({purpose: oParam.purpose, data: oData}, function(oError, sWire){ if(oError){ fail(oError); return; } fnCallback(sWire); }); return; } if(oParam.auth_mode === 'decryptClient'){ openView({url: oParam.url, params: oParam.params, purpose: oParam.purpose, keep: oParam.keep, loading: oParam.loading}, function(oError, mData){ if(oError){ fail(oError); return; } fnCallback(mData); }); return; } fail(new CryptError('E_FORMAT')); } /** * 실패 콜백 값인지 — "ERROR: " 접두어로 판정한다(전송형은 항상 "GWENC1." 로 시작. AuthSSLManager.isError 의 * 부분 문자열 판정은 base64 본문에 우연히 'ERROR' 가 들어 있어도 실패로 볼 수 있어 쓰지 않는다) */ function isError(mOutput) { return typeof mOutput === 'string' && mOutput.indexOf('ERROR: ') === 0; } /** * 이 브라우저에서 쓸 수 있는지 — getRandomValues·forge·JSON·XHR, 표시용은 Worker 까지 */ function isSupported(bForView) { if(getCrypto() === null || !root.forge || typeof JSON === 'undefined' || typeof root.XMLHttpRequest === 'undefined'){ return false; } if(bForView === true && typeof root.Worker === 'undefined'){ return false; } try{ getForge(); }catch(e){ return false; } return true; } /* --------------------------------------------------------------------------------------------- * 표시용 화면 도우미 * ------------------------------------------------------------------------------------------- */ /** * 개인정보 칸 "불러오는 중" 표시 — 입력칸은 placeholder, 그 밖의 비어 있는(공백만 있는) 요소는 textContent * * @param {Array} aIds 요소 id 목록 * @param {Boolean} bOn */ function markLoading(aIds, bOn) { var oDoc = root.document; for(var i = 0; i < aIds.length; i++){ var oEl = oDoc.getElementById(aIds[i]); if(oEl === null){ continue; } var sTag = oEl.nodeName.toLowerCase(); if(sTag === 'input' || sTag === 'textarea'){ if(bOn === true){ oEl.setAttribute('placeholder', MESSAGE_LOADING); }else if(oEl.getAttribute('placeholder') === MESSAGE_LOADING){ oEl.removeAttribute('placeholder'); } }else if(sTag !== 'select' && sTag !== 'img'){ if(bOn === true && oEl.getElementsByTagName('*').length === 0 && String(oEl.textContent).replace(/[\s\u00a0]+/g, '') === ''){ oEl.textContent = MESSAGE_LOADING; oEl.setAttribute('data-bldlc-loading', '1'); }else if(bOn !== true && oEl.getAttribute('data-bldlc-loading') === '1'){ oEl.removeAttribute('data-bldlc-loading'); if(oEl.textContent === MESSAGE_LOADING){ oEl.textContent = ''; } } } } } var BuilderLocalCrypt = { VERSION: VERSION, MESSAGE_LOADING: MESSAGE_LOADING, nonceUrl: '/chtml/builder_nonce.php', workerUrl: null, isSupported: isSupported, requestNonce: requestNonce, encryptInput: encryptInput, prepareView: prepareView, openView: openView, disposeView: disposeView, weave: weave, isError: isError, markLoading: markLoading, /* worker·교차 검증 CLI 가 쓰는 하위 함수 — 화면은 위 API 만 쓴다 */ core: { randomBytes: randomBytes, serialize: serialize, sealEnvelope: sealEnvelope, parseWire: parseWire, openEnvelope: openEnvelope, publicKeyFromPem: publicKeyFromPem, publicKeyToDer: publicKeyToDer, derToPem: derToPem, browserKid: browserKid, generateViewKey: generateViewKey, openViewEnvelope: openViewEnvelope } }; root.BuilderLocalCrypt = BuilderLocalCrypt; })(typeof self !== 'undefined' ? self : this);