빌드노트
PUBLIC KEY PEM을 WebCrypto에 넣었더니 DataError가 난다
PUBLIC KEY PEM을 UTF-8 바이트로 바꾸는 것과 SPKI DER로 디코딩하는 것은 다르다. 같은 합성 RSA 키에서 import 실패·성공과 원문·변조 서명 검증을 비교하고, RSA PUBLIC KEY의 PKCS#1 구조를 라벨만 바꿔 가져올 수 없는 이유를 설명한다.
-----BEGIN PUBLIC KEY-----로 시작하는 PEM 문자열을 TextEncoder로 바꿔 importKey('spki', ...)에 넣으면 키를 가져오지 못할 수 있다. 바이트 배열이라는 조건만 맞춘 셈이다. spki가 요구하는 것은 PEM 문자의 UTF-8 바이트가 아니라, PEM 안의 Base64를 디코딩한 SPKI DER 바이트다.
TextEncoder는 문자열을 UTF-8로 인코딩한다. 헤더를 지우거나 Base64를 해석하지 않는다. 헤더와 꼬리만 제거한 뒤 남은 Base64 문자열을 TextEncoder에 넣어도 아직 DER가 아니다. 이 역할은 WHATWG의 TextEncoder 정의 (새 탭에서 열림)에서 확인할 수 있다.
PEM은 바이너리를 텍스트로 전달하기 위한 포장이다. RFC 7468 §2·§13 (새 탭에서 열림)은 PUBLIC KEY 라벨 안의 구조를 SubjectPublicKeyInfo로 정한다. 흔히 SPKI라고 부르는 이 구조에는 키 알고리즘 정보와 공개키가 들어 있다. Web Crypto의 KeyFormat 정의 (새 탭에서 열림)는 spki 입력으로 그 구조의 DER 인코딩을 요구한다.
PUBLIC KEY 블록을 디코딩해서 전달한다
기존 코드에서 new TextEncoder().encode(pem)을 넘기는 위치를 찾았다면, 아래처럼 디코딩을 거친 바이트로 바꾼다. pem에는 BEGIN PUBLIC KEY와 END PUBLIC KEY가 있는 공개키 문자열 한 개가 들어 있어야 한다. 여기서는 RSASSA-PKCS1-v1_5와 SHA-256 서명 검증에 쓸 RSA 공개키를 대상으로 한다.
다음 코드는 라벨을 확인하고, 줄바꿈을 제거한 Base64를 바이트로 복원한 뒤 검증용 키를 가져온다.
const rsa = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
function spkiDerFromPem(pem) {
const match = /^-----BEGIN PUBLIC KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END PUBLIC KEY-----$/.exec(pem.trim());
if (!match) throw new TypeError('Expected one PUBLIC KEY PEM block');
const base64 = match[1].replace(/[\r\n]/g, '');
return Uint8Array.from(atob(base64), ch => ch.charCodeAt(0));
}
function importRsaVerifyKey(pem) {
return globalThis.crypto.subtle.importKey(
'spki', spkiDerFromPem(pem), rsa, false, ['verify']
);
}
기존 호출은 await importRsaVerifyKey(pem)으로 바꿀 수 있다. atob()의 결과는 각 문자가 한 바이트 값을 나타내는 문자열이므로 charCodeAt(0)으로 옮긴다. 이 결과를 다시 TextEncoder에 넣으면 UTF-8 인코딩을 거쳐 바이트가 바뀔 수 있다.
이 함수는 LF·CRLF 줄바꿈과 마지막 줄바꿈 유무를 처리한다. 입력 전체의 바깥 공백은 제거하지만, Base64 줄 안에 공백이 끼어 있거나 설명문·여러 PEM 블록이 섞인 입력은 받지 않는다. 모든 PEM 변형을 읽는 범용 파서가 아니라, 공개키 한 개를 받는 코드의 입력 조건을 명시한 것이다. 라벨 검사만으로 내부 구조까지 보장되지는 않으며, 실제 DER 해석은 importKey()가 수행한다.
또한 PUBLIC KEY라는 라벨이 RSA를 뜻하지는 않는다. EC 공개키도 SPKI로 담을 수 있으므로, 다른 알고리즘의 키라면 위 rsa 매개변수를 그대로 쓰지 않는다. 키를 제공하는 쪽의 알고리즘과 실제 서명 규약을 확인해야 한다.
같은 합성 키에서 입력만 바꿔 봤다
Windows의 Node.js 22.23.2, OpenSSL 3.5.7에서 새 RSA 2048비트 키쌍을 만들었다. 공개키를 SPKI DER로 내보내고, 그 DER를 Base64로 감싸 PEM도 만들었다. 개인키는 프로세스 메모리에만 두고 파일이나 출력으로 내보내지 않았다.
직접 비교하려면 Node.js 22가 설치된 환경에서 빈 pem-check.mjs 파일에 위 함수·상수 정의와 다음 코드를 순서대로 넣는다. 별도 패키지는 필요 없다. node pem-check.mjs로 실행하면 같은 공개키의 PEM 입력 실패와 DER 입력 성공을 비교할 수 있다. Buffer는 이 Node 예제에서 공개키의 Base64 포장을 만드는 데 쓴다.
const { subtle } = globalThis.crypto;
const pair = await subtle.generateKey({
...rsa,
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1])
}, false, ['sign', 'verify']);
const der = await subtle.exportKey('spki', pair.publicKey);
const base64 = Buffer.from(der).toString('base64');
const pem = `-----BEGIN PUBLIC KEY-----\n${base64.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----\n`;
try {
await subtle.importKey('spki', new TextEncoder().encode(pem), rsa, false, ['verify']);
console.log('PEM UTF-8: unexpected success');
} catch (error) {
console.log('PEM UTF-8:', error.name);
}
const key = await importRsaVerifyKey(pem);
console.log('decoded DER matches:', Buffer.from(spkiDerFromPem(pem)).equals(Buffer.from(der)));
console.log('DER import:', key.type);
const message = new TextEncoder().encode('order=100');
const changed = new TextEncoder().encode('order=101');
const signature = await subtle.sign(rsa.name, pair.privateKey, message);
console.log('original:', await subtle.verify(rsa.name, key, signature, message));
console.log('tampered:', await subtle.verify(rsa.name, key, signature, changed));
실행 결과는 다음과 같았다.
PEM UTF-8: DataError
decoded DER matches: true
DER import: public
original: true
tampered: false
decoded DER matches: true는 디코딩한 바이트가 처음 내보낸 DER와 같다는 뜻이다. 가져오기에 성공한 키로 order=100의 서명을 검증하면 true, 같은 서명에 order=101을 넣으면 false가 나왔다. 메시지는 텍스트이므로 여기의 TextEncoder 사용은 맞다. 키 포장을 해제하는 단계와 메시지를 바이트로 만드는 단계의 입력이 다르다.
별도 비교에서도 LF·CRLF 각각에 마지막 줄바꿈이 있는 경우와 없는 경우, 네 입력 모두 동일한 DER로 복원됐고 원문·변조 검증 결과도 같았다. 반면 PEM 전체뿐 아니라 Base64 문자만 UTF-8로 바꾼 입력도 실패했다. 이 환경의 오류는 DataError: Invalid keyData였다. W3C의 RSA 가져오기 절차 (새 탭에서 열림)는 SPKI 파싱 실패를 DataError로 처리하지만, 상세 오류 문구가 모든 구현에서 같다는 뜻은 아니다.
RSA PUBLIC KEY는 라벨만 고쳐서 가져올 수 없다
입력이 -----BEGIN RSA PUBLIC KEY-----로 시작한다면 위 함수가 거절하는 것이 맞다. 이번 실험에서 같은 RSA 공개키를 Node의 type: 'pkcs1'으로 내보냈을 때 이 라벨이 붙었다. 그 안의 DER는 RFC 8017 부록 A.1.1의 RSAPublicKey (새 탭에서 열림) 구조이며, SPKI 전체와는 다르다.
실제로 PKCS#1 DER를 importKey('spki', ...)에 넘기면 실패했다. PEM의 RSA PUBLIC KEY를 PUBLIC KEY로 바꾼 뒤 디코딩해도 같은 DataError가 났다. 문자 라벨을 바꿔도 내부 ASN.1 구조는 바뀌지 않기 때문이다.
이 경우에는 공개키를 제공하는 쪽에서 SPKI로 내보내게 하거나, PKCS#1을 읽을 수 있는 도구로 키를 읽고 SPKI로 다시 내보내야 한다. Node에서는 공개키 문자열을 담은 pkcs1Pem을 createPublicKey(pkcs1Pem)으로 읽고, 그 결과에 export({ type: 'spki', format: 'der' })를 적용할 수 있다. Node 22.23.2의 공개키 내보내기 문서 (새 탭에서 열림)는 구조를 고르는 type과 표현을 고르는 format을 따로 받는다. 이 변환도 실행했으며, 원래 SPKI DER와 같은 바이트가 나왔고 서명 검증도 원문 true·변조 false였다.
디코딩 뒤에도 실패한다면 오류 이름만으로 같은 문제라고 판단하지 않는다. 실제로 전달한 바이트가 SPKI인지, 키 알고리즘과 가져오기 매개변수가 맞는지 확인한다. 위 비교는 새 합성 RSA 키와 Node WebCrypto에서 실행한 결과다. 실제 브라우저에서의 실행, EC 등 다른 키 유형, 인증서 파일을 공개키로 추출하는 과정은 확인하지 않았다.