빌드노트
Web Crypto AES-GCM을 Node에서 풀 때는 뒤 16바이트를 분리한다
Web Crypto의 AES-GCM 결과는 암호문 뒤에 인증 태그가 붙지만 Node는 태그를 별도로 받는다. 128비트 태그를 16바이트로 분리·결합해 양방향 복호화를 확인하고, 잘못된 경계가 거부되는 지점과 길이가 명시된 교환 계약을 설명한다.
Web Crypto의 AES-GCM 암호화 결과를 Node의 decipher.update()에 통째로 넘기면, 같은 원시 키·IV·AAD를 사용해도 decipher.final()에서 인증이 실패할 수 있다. 먼저 받은 버퍼에 인증 태그까지 들어 있는지 확인한다. tagLength: 128인 Web Crypto 결과는 암호문 뒤에 16바이트 태그가 붙어 있다. Node에서는 앞의 암호문만 update()에 넣고, 태그는 setAuthTag()에 따로 전달해야 한다.
W3C AES-GCM 연산 규격 (새 탭에서 열림)은 암호화 결과를 C || T로 정의한다. C는 암호문, T는 인증 태그이며 ||는 바이트 연결이다. 복호화할 때도 결과의 끝에서 태그를 꺼낸다. 반면 Node의 setAuthTag 문서 (새 탭에서 열림)는 받은 태그를 별도 인자로 설정하도록 한다. 키가 같은지와 별개로 두 API의 입출력 모양을 맞춰야 한다.
Node에 넘길 버퍼를 둘로 나눈다
다음 함수는 Web Crypto 결과의 마지막 16바이트를 분리해 Node에 전달한다. combined는 IV나 별도 헤더가 없는 C || T의 Buffer, key는 32바이트 원시 AES 키, iv는 암호화에 사용한 12바이트 IV다. aad도 송신 측의 additionalData와 같은 바이트로 준비한다.
import { createDecipheriv } from 'node:crypto';
export function decryptWebCrypto(combined, key, iv, aad) {
const tagBytes = 16;
if (combined.length < tagBytes) {
throw new RangeError('AES-GCM result is shorter than its tag');
}
const cut = combined.length - tagBytes;
const decipher = createDecipheriv('aes-256-gcm', key, iv, {
authTagLength: tagBytes,
});
decipher.setAAD(aad);
decipher.setAuthTag(combined.subarray(cut));
return Buffer.concat([
decipher.update(combined.subarray(0, cut)),
decipher.final(),
]);
}
여기서 Web Crypto의 tagLength는 비트, Node의 authTagLength는 바이트다. 두 쪽에 같은 숫자 128을 넣는 것이 아니라 각각 128과 16을 지정한다. 이 함수는 그 길이를 고정한 교환에만 쓴다. Web Crypto의 tagLength (새 탭에서 열림)와 Node의 authTagLength (새 탭에서 열림)에 각 단위와 동작이 설명돼 있다.
Web Crypto가 반환한 ArrayBuffer는 Buffer.from(result)로 바꿀 수 있다. 전송 중 Base64 문자열로 바꿨다면 먼저 Buffer.from(received, 'base64')로 복원한 뒤 바이트를 자른다. 문자열의 마지막 16글자를 떼는 것은 이 경계가 아니다. 전체 버퍼를 계속 update()에 넣으면서 setAuthTag()만 추가해도 해결되지 않는다. 태그가 암호문 입력에 중복해서 포함되기 때문이다.
반환문은 final()이 성공해야 완성된다. update()가 내놓은 중간 바이트를 먼저 사용하지 않는다. Node 문서도 인증 확인은 final 호출에서 성립한다 (새 탭에서 열림)고 설명한다.
같은 입력으로 양방향을 확인한다
다음은 위 함수에 새 합성 키와 입력을 넣어 Web Crypto → Node 복호화를 확인하는 코드다. 빈 gcm-layout.mjs 파일에 위 함수 블록을 저장하고, 아래 블록을 이어 붙인다. 외부 패키지는 필요 없다. 실험은 Windows 11의 Node.js v22.23.2, OpenSSL 3.5.7에서 실행했다. 여기서 Web Crypto는 Node의 webcrypto.subtle이며 실제 브라우저를 실행한 결과는 아니다.
import { createCipheriv, randomBytes, webcrypto } from 'node:crypto';
const { subtle } = webcrypto;
const key = randomBytes(32);
const webKey = await subtle.importKey(
'raw', key, 'AES-GCM', false, ['encrypt', 'decrypt'],
);
const plain = randomBytes(23);
const aad = Buffer.from('layout-v1', 'utf8');
const iv = randomBytes(12);
const params = { name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 };
const combined = Buffer.from(await subtle.encrypt(params, webKey, plain));
console.log('Web->Node', combined.length,
decryptWebCrypto(combined, key, iv, aad).equals(plain));
반대 방향에서는 Node의 암호문 뒤에 getAuthTag() 결과를 붙여 Web Crypto에 전달한다. 아래 블록도 같은 파일 끝에 이어 붙인다. 같은 합성 키와 입력을 쓰지만, 새 암호화이므로 IV는 새로 만든다. getAuthTag 문서 (새 탭에서 열림)에 따라 final() 뒤에 태그를 얻는다.
const iv2 = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv2, { authTagLength: 16 });
cipher.setAAD(aad);
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
const tag = cipher.getAuthTag();
const nodeCombined = Buffer.concat([ciphertext, tag]);
const restored = Buffer.from(await subtle.decrypt(
{ name: 'AES-GCM', iv: iv2, additionalData: aad, tagLength: 128 },
webKey, nodeCombined,
));
console.log('Node->Web', ciphertext.length, tag.length,
nodeCombined.length, restored.equals(plain));
세 코드 블록을 순서대로 저장한 폴더에서 다음 명령을 실행한다. 키와 평문 값은 출력하지 않고 길이와 원본 일치 여부만 본다.
node ./gcm-layout.mjs
직접 실행한 출력은 다음과 같다.
Web->Node 39 true
Node->Web 23 16 39 true
첫 줄은 Web Crypto 결과 39바이트를 분리해 원래 입력을 복원했다는 뜻이다. 둘째 줄은 Node 암호문 23바이트와 태그 16바이트를 합친 39바이트가 Web Crypto에서도 복호화됐다는 뜻이다. 이 입력에서는 합친 길이가 평문 바이트 수 + 16이었다. 추가로 빈 입력도 실행했으며, 결과 16바이트 전체가 태그인 경우 같은 함수로 복호화됐다. 따라서 최소 길이 검사는 16 이하가 아니라 16 미만을 거부한다.
실패 조건도 별도로 비교했다. Web Crypto 결과 전체를 넣고 태그를 설정하지 않은 경우와, 올바른 태그를 설정하면서 결과 전체를 암호문으로도 넣은 경우는 모두 Node의 final()에서 거부됐다. 끝의 15바이트를 태그로 넘기면 authTagLength: 16과 달라 setAuthTag()에서 거부됐다. 태그 길이를 16바이트로 맞춰도 범위를 한 바이트 앞당겨 잡으면 final()에서 실패했다. 길이 확인만으로 경계가 맞았다고 판단할 수는 없다.
양방향 출력이 true인데 실제 교환만 실패한다면, 전송을 거친 바이트에서 같은 분리 규칙이 적용되는지 본다. C || T 앞에 IV나 헤더를 붙였는지, Base64를 먼저 복원했는지, 같은 키를 선택했는지, IV와 AAD가 송신 시점과 같은지 확인한다. 인증 실패만으로 태그 경계 오류라고 확정할 수는 없다.
전송 형식에는 길이와 순서를 함께 적는다
이 예제에 대응하는 교환 계약은 AES-256-GCM, 12바이트 IV, 128비트 태그를 고정하고, IV는 별도 필드에, 암호화 결과는 C || T에 담는 것이다. 수신자는 결과가 최소 16바이트인지 확인하고 끝의 16바이트를 태그로 해석한다. AAD는 양쪽이 같은 바이트를 재현할 규칙까지 정해야 한다. JSON 전송이라면 IV와 C || T 각각의 인코딩도 명시한다. 원시 키는 메시지에 함께 넣지 않는다.
복호화에는 해당 암호화 때의 IV를 전달하지만, 같은 키로 새로 암호화할 때는 IV를 재사용하면 안 된다. Web Crypto의 IV 조건 (새 탭에서 열림)도 같은 키의 암호화마다 고유한 IV를 요구한다. 예제는 매번 새 난수를 만들었고, 별도 실험에서는 두 암호화의 IV가 서로 다른지도 확인했다.
이번 실행은 두 API 사이에서 암호문과 태그를 분리·결합하는 계약을 확인한 것이다. 다른 태그 길이, 실제 브라우저와 서버 사이의 전송은 실행하지 않았다. 키 배포·보관·교체, 재전송 공격 방지, 여러 프로세스에 걸친 IV 중복 방지까지 갖춘 통신 프로토콜의 보안을 검증한 예제는 아니다.