/**
* 빌더 자체 암호화 브라우저 모듈 — 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);