Web Crypto API는 강력한 암호 프리미티브를 제공한다. 하지만 스트림용 인터페이스는 없다. encrypt() 와 decrypt() 는 ReadableStream 이 아니라 하나의 ArrayBuffer 를 대상으로 동작한다.
큰 파일이나 청크 단위 업로드를 메모리에 전부 버퍼링하지 않고 암호화하려면, 프레이밍과 레코드 분할 로직을 직접 작성해야 한다. 이 글에서는 @hsblabs/web-stream-extras의 encryption 서브패스로 그 부분을 건너뛰는 방법을 설명한다.
npm install @hsblabs/web-stream-extrasNode.js ≥22 또는 Web Streams + Web Crypto 를 지원하는 최신 브라우저에서 동작한다.
스트림 암호화와 복호화
encryptStream 과 decryptStream 은 ReadableStream<Uint8Array> 를 받아 같은 타입으로 돌려주므로 기존 파이프라인에 어댑터 없이 넣을 수 있다.
import { readableFromChunks, readAllBytes, stringToBinary, binaryToString,} from "@hsblabs/web-stream-extras";import { encryptStream, decryptStream,} from "@hsblabs/web-stream-extras/encryption";
const key = crypto.getRandomValues(new Uint8Array(32));
const plaintext = readableFromChunks([stringToBinary("secret payload")]);const encrypted = encryptStream(key, plaintext);const decrypted = decryptStream(key, encrypted);
console.log(binaryToString(await readAllBytes(decrypted))); // "secret payload"암호화된 결과도 표준 ReadableStream<Uint8Array> 다. fetch 본문에 연결해도 되고, File System Access API 에 써도 되고, 스트림 소비자를 기대하는 곳 어디든 넘길 수 있다.
사용자가 고른 파일 암호화하기
File.stream() 은 이미 ReadableStream<Uint8Array> 를 반환하므로 별도 변환이 필요 없다.
async function encryptFile(file: File, key: Uint8Array): Promise<Uint8Array> { const plaintext = file.stream() as ReadableStream<Uint8Array>; const encrypted = encryptStream(key, plaintext); return readAllBytes(encrypted);}결과는 단순한 바이트 배열이다. 저장해도 되고, 업로드해도 되고, 다른 스트림에 넘겨도 된다.
webCryptoStream 으로 스트림별 키 관리하기
실제 애플리케이션에서는 보통 이런 요구가 생긴다.
- 파일마다 다른 암호화 키를 쓰고 싶다
- 그 키를 파일 레코드와 함께 저장하고 싶다
- 파일별 키를 마스터 키로 보호해 원시 키 값이 평문으로 남지 않게 하고 싶다
webCryptoStream 은 그 패턴을 감싼다. 추출 불가능한 AES-GCM CryptoKey 를 받고, 스트림별 키를 base64url 문자열로 생성하고 관리하는 헬퍼를 돌려준다.
import { webCryptoStream } from "@hsblabs/web-stream-extras/encryption";
const masterKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],);
const crypto$ = webCryptoStream(masterKey);파일 암호화:
// streamKey 는 base64url 문자열 — 파일 레코드와 함께 저장한다const streamKey = await crypto$.createStreamKey();
const plaintext = file.stream() as ReadableStream<Uint8Array>;const encrypted = await crypto$.encrypt(streamKey, plaintext);const encryptedBytes = await readAllBytes(encrypted);
// 저장: { encryptedBytes, streamKey }나중에 복호화:
const encrypted = readableFromChunks([encryptedBytes]);const decrypted = await crypto$.decrypt(streamKey, encrypted);const result = await readAllBytes(decrypted);streamKey 문자열은 마스터 키로 AES-GCM 암호화된다. 데이터베이스에 저장해도 안전하고, 원시 키 값이 평문으로 드러나지 않는다.
라이브러리가 하는 일과 하지 않는 일
내부에서 처리하는 것:
- 평문 스트림을 고정 크기 레코드로 분할
- HKDF 로 레코드별 고유 키와 nonce 를 도출
- AES-GCM 으로 각 레코드를 인증하고 복호화 시 변조를 감지
하지 않는 것 — 이 부분은 애플리케이션 책임이다:
- 키 저장과 영속화
- 패스워드 기반 키 파생(PBKDF2 / Argon2는 직접 넣어야 함)
- 사용자 인증과 접근 제어
라이브러리는 스트림 변환 계층에 머문다.
다른 transform과 합성하기
입출력이 모두 ReadableStream<Uint8Array> 이라서 합성은 자연스럽다.
// 압축 후 암호화const compressed = plaintext.pipeThrough(new CompressionStream("gzip"));const encrypted = encryptStream(key, compressed);// 암호화된 스트림을 그대로 업로드await fetch("/upload", { method: "POST", body: encrypted, duplex: "half",});
hsb.horse