logo hsb.horse
← 블로그 목록으로 돌아가기

블로그

Web Crypto로 브라우저에서 바이트 스트림 암호화하기

Web Crypto는 강력한 암호 프리미티브를 제공하지만 스트림용 인터페이스는 없다. @hsblabs/web-stream-extras 의 encryption 서브패스로 ReadableStream<Uint8Array> 를 암호화하는 방법, 파일 암호화, 스트림별 키 관리, 파이프라인 합성까지 정리한다.

게시일:

Web Crypto API는 강력한 암호 프리미티브를 제공한다. 하지만 스트림용 인터페이스는 없다. encrypt()decrypt()ReadableStream 이 아니라 하나의 ArrayBuffer 를 대상으로 동작한다.

큰 파일이나 청크 단위 업로드를 메모리에 전부 버퍼링하지 않고 암호화하려면, 프레이밍과 레코드 분할 로직을 직접 작성해야 한다. 이 글에서는 @hsblabs/web-stream-extrasencryption 서브패스로 그 부분을 건너뛰는 방법을 설명한다.

Terminal window
npm install @hsblabs/web-stream-extras

Node.js ≥22 또는 Web Streams + Web Crypto 를 지원하는 최신 브라우저에서 동작한다.


스트림 암호화와 복호화

encryptStreamdecryptStreamReadableStream<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 으로 스트림별 키 관리하기

실제 애플리케이션에서는 보통 이런 요구가 생긴다.

  1. 파일마다 다른 암호화 키를 쓰고 싶다
  2. 그 키를 파일 레코드와 함께 저장하고 싶다
  3. 파일별 키를 마스터 키로 보호해 원시 키 값이 평문으로 남지 않게 하고 싶다

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",
});

npm · GitHub