전자서명 연동
전자서명은 2단계 외부 서명(External Signing) 구조입니다. 백엔드는 서명 대상 해시만 만들어 주고, 실제 서명은 인증서를 가진 프론트엔드에서 일어납니다. 개인키는 백엔드로 전송되지 않습니다.
개발자가 구현하는 것은 signer(프론트엔드 서명 함수), digest 엔드포인트, sign 엔드포인트 세 가지뿐입니다. 나머지 조율은 자동으로 동작합니다.
1.전체 흐름
전자서명은 해시 계산 → 서명 → 심기의 왕복입니다. viewer.sign()을 부르면 뷰어가 자동으로 ① 백엔드에 서명 대상 해시(digest)를 요청하고 → ② 그 해시를 signer로 서명해 PKCS#7을 받고 → ③ 백엔드에 그 값을 PDF에 심도록(sign) 보냅니다. 개발자는 signer 함수(3절)와 백엔드 digest·sign 엔드포인트(4절)만 구현하면 됩니다.
프론트엔드가 만든 PKCS#7은 SignatureField.signature.contents(hex)에 실려 문서 모델(JSON)로 백엔드에 전달되고, 거기서 getSigner().sign()이 이 값을 읽어 서명 자리에 주입합니다. 이 배선은 뷰어가 하므로 직접 다룰 일은 없지만, 뷰어 없이 API만 쓸 때는 이 전제(contents에 hex, signingSignatureFieldIds에 대상 id)를 직접 맞춰야 합니다.
2.viewer.sign() 파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
signingSignatureFields | SignatureField[] | Set | 단일 | 서명할 필드. 이미 서명된 필드는 자동 제외 |
signingLockFields | Field[] | 서명과 함께 읽기전용으로 잠글 필드 |
name / reason / location / contactInfo | string | 서명 정보 (PDF 서명 사전에 기록) |
한 번의 서명 요청에 DIGITAL_SIGNATURE 필드는 1개만 포함할 수 있습니다 (2개 이상이면 백엔드가 IllegalArgumentException). 여러 개를 넘기면 뷰어가 필드별로 digest→sign을 반복 수행합니다. STAMP 필드는 함께 여러 개 포함 가능합니다.
3.signer: 프론트엔드 서명 함수 (개발자 구현 ①)
digestValue(hex)를 받아 PKCS#7 detached 서명(hex)을 돌려주면 됩니다. 무엇을 쓰든 자유입니다. 공동인증서 툴킷, 간편인증 등 이 계약만 지키면 됩니다.
실제 서명은 인증서 툴킷이 담당하며, 사용처가 직접 준비합니다. 한국 공동인증서라면 SecuKit(KICA/SignGate)이 예이며, 이 경우 프론트엔드 서명 모듈, 백엔드 검증/타임스탬프용 .jar(classpath), 해당 CA의 라이선스가 함께 필요합니다. CoreDocs는 그 툴킷이 만든 PKCS#7만 받으므로, 어떤 툴킷을 쓰든(간편인증 포함) 이 signer 계약만 지키면 됩니다.
const signer = {
sign: async ({ digestValue }) => {
// digestValue = 백엔드가 만든 SHA-256 해시 (hex 문자열)
// 인증서 선택 → 해시를 원문으로 PKCS#7 detached 서명 생성
const pkcs7 = await 인증서툴킷.generatePKCS7SignedDataDetached({
certType: 'SignCert',
sourceString: digestValue,
certID: 선택한인증서,
});
return pkcs7; // PKCS#7 hex 문자열
},
};
viewer.setSigner(signer);signer에 넘어오는 것은 원문이 아니라 해시입니다. 인증서 툴킷이 “전달받은 값을 원문으로 삼아 detached PKCS#7을 만드는” 방식을 지원해야 하며, 서명 속성(signedAttributes) 구성은 툴킷이 처리합니다. 툴킷이 원문 전체를 요구하는 방식이라면 이 흐름과 맞지 않으므로 툴킷 스펙을 먼저 확인하십시오.
4.백엔드 엔드포인트 (개발자 구현 ②③)
saveFormData는 뷰어가 만드는 FormData입니다. document 파트(문서 모델 JSON, application/json)와 imageFileList 파트(추가/변경된 이미지, 파일명=주석 id)로 구성됩니다.
// 1단계: 문서 모델 반영 후 서명 대상 해시 생성
@PostMapping("/document/{name}/digitalsignature/digest")
public String digest(@PathVariable String name,
@RequestPart("document") Document document,
@RequestPart(name = "imageFileList", required = false) MultipartFile[] imageFileList) throws IOException {
document.applyToPDF(pdfPath.resolve(name), toMap(imageFileList));
return Hex.getString(document.getSigner().digest());
}
// 2단계: contents의 PKCS#7을 주입하고 증분 저장
@PostMapping("/document/{name}/digitalsignature/sign")
public Document sign(@PathVariable String name,
@RequestPart("document") Document document,
@RequestPart(name = "imageFileList", required = false) MultipartFile[] imageFileList) throws IOException {
document.applyToPDF(pdfPath.resolve(name), toMap(imageFileList));
document.getSigner().sign(pdfPath.resolve(name), cachePath.resolve(name));
return document;
}digest와 sign은 각각 applyToPDF를 다시 실행합니다. 서명 해시는 최종 PDF의 ByteRange 바이트열에 대한 것이라, sign 단계가 digest 때와 바이트 단위로 동일한 PDF를 재생성해야 서명이 맞습니다. digest 이후에 서명 정보(name / reason 등)나 다른 필드, 주석을 바꾸면 ByteRange가 달라져 검증 시 TAMPERED로 나옵니다. 두 호출 사이에는 문서 모델을 그대로 두십시오.
5.예외
| 예외 | 발생 조건 |
|---|---|
IllegalArgumentException | 서명 대상 필드 id가 없음 / DIGITAL_SIGNATURE 필드가 2개 이상 |
RuntimeException("Invalid license") | 라이선스 무효 (sign 시) |
IOException | PDF 로드/저장 실패. 실패해도 원본 파일은 보존됨 |