기록

NFC 진입 URL과 일회성 자격증명

TL;DR

  • URL 쿼리는 항상 화면 상태가 아니다. NFC 태그가 만든 일회성 자격증명일 수 있다.
  • SDM/SUN 계열 태그는 읽을 때마다 동적 파라미터를 만들고, 서버는 카운터로 재사용을 막을 수 있다.
  • 자격증명 쿼리를 라우팅 중 계속 붙이면 같은 값을 재사용하게 되어 보안 모델과 충돌한다.
  • 같은 쿼리를 다시 보내도 되는 값은 URL 상태로 둘 수 있고, 다시 보내면 안 되는 값은 진입 요청에서만 써야 한다.

NFC 웹 프로젝트에서 태그를 찍으면 브라우저가 URL을 연다. 이때 URL에 담긴 값이 화면 상태인지 자격증명인지에 따라 라우팅 전략이 달라진다. 두 프로젝트는 겉으로 비슷했다.

프로젝트 진입 URL
첫 번째 NFC 웹 /?picc_data=...&enc=...&cmac=...
두 번째 NFC 키링 웹 /keyring?pstNo=123

첫 번째 프로젝트에서는 파일 라우팅을 줄이고 화면 상태를 클라이언트 store로 내렸다. 두 번째 프로젝트에서는 router.push('/keyring?pstNo=123')를 그대로 쓸 수 있었다. 차이는 쿼리의 의미였다.

고정 URL과 동적 URL

NFC 태그에는 NDEF URI 레코드를 쓴다. 일반적인 태그라면 같은 URL이 열린다.

/keyring?pstNo=123

이 값은 화면 상태다. 같은 URL을 다시 열어도 같은 콘텐츠를 보여주면 된다. 반면 SDM(Secure Dynamic Messaging) 또는 SUN(Secure Unique NFC) 계열 동작에서는 태그가 열어주는 URL이 매번 달라질 수 있다.

/?picc_data=<hex>&enc=<hex>&cmac=<hex>

태그는 내부 UID와 읽기 카운터를 암호화하거나 MAC과 함께 URL에 넣는다. 서버는 이 값을 복호화·검증하고, 저장된 카운터와 비교해 재사용을 막는다.

NFC tag read
  -> picc_data / enc / cmac 생성
  -> browser GET
  -> server decrypt/verify
  -> uid, counter 확인
  -> 저장된 counter 갱신

이 구조에서 쿼리는 화면 상태가 아니라 일회성 자격증명이다.

카운터 기반 재사용 방지

서버의 검증은 대략 이런 모양이었다.

const { uid, count } = await decryptTagPayload({ picc, enc, cmac });
const row = await findByUid(uid);

if (row.count <= count) { await updateCount({ uid, count: count + 1 }); return { ok: true }; }

return { ok: false };

첫 요청이 통과하면 서버에 저장된 count는 count + 1이 된다. 같은 URL을 다시 보내면 태그가 준 count는 그대로이고, 서버 값은 이미 더 커져 있다. 두 번째 요청은 실패한다. 이게 보안 모델이다. 복사된 URL이나 캡처된 파라미터를 다시 보내도 통과하지 않게 만든다. 따라서 라우팅할 때마다 같은 쿼리를 다시 붙이는 방식은 이 보안 모델과 충돌한다.

라우팅이 자격증명을 소모하는 방식

파일 라우팅으로 화면을 나누면 경로 이동이 생긴다.

/?picc_data=...&enc=...&cmac=...
  -> /create-profile
  -> /fortune
  -> /result

이동 후 주소창에는 진입 쿼리가 사라진다. 새로고침이나 뒤로가기가 발생하면 서버는 더 이상 자격증명을 받지 못한다. 반대로 쿼리를 계속 붙이는 래퍼를 만들면 다른 문제가 생긴다.

function pushWithTagParams(url: string) {
  const next = new URL(url, location.origin);
  next.searchParams.set("picc_data", picc);
  next.searchParams.set("enc", enc);
  next.searchParams.set("cmac", cmac);
  return router.push(next.toString());
}

이 코드는 모든 화면 이동마다 같은 자격증명을 다시 보낸다. 첫 요청에서 카운터를 갱신했다면 바로 다음 요청부터 재사용 공격과 같은 모양이 된다. 보안 검사를 유지하려면 같은 자격증명을 다시 보내면 안 된다. 같은 자격증명을 계속 보내려면 카운터 검사를 포기해야 한다. 둘은 동시에 만족하기 어렵다.

화면 상태로 내리기

첫 번째 프로젝트에서는 라우트 파일을 줄이고 하나의 페이지 안에서 화면 상태만 바꾸는 방식으로 정리했다.

type Screen = "/" | "/create-profile" | "/fortune" | "/result";

const useScreenStore = create<{
  screen: Screen;
  go: (screen: Screen) => void;
}>((set) => ({
  screen: "/",
  go: (screen) => set({ screen }),
}));

서버 요청은 NFC 진입 한 번에서만 발생한다.

NFC URL 진입
  -> getServerSideProps에서 복호화/카운터 검증
  -> 통과하면 client screen store로 내부 화면 전환
  -> 이후 화면 이동은 서버 자격증명 재검증 없음

이 선택에는 포기하는 것도 있다.

포기한 것 이유
새로고침 복구 진입 URL을 다시 보내면 카운터에 막힘
화면별 딥링크 화면 상태가 URL에 없음
브라우저 뒤로가기 내부 screen state와 history가 분리됨

보안 요구가 더 강하면 이 제약을 UX 사양으로 받아들여야 한다. 사용자가 새로고침하거나 뒤로가기를 누르면 태그를 다시 찍게 하는 쪽이 보안 모델과 맞다.

상태 쿼리

두 번째 NFC 키링 프로젝트의 쿼리는 성격이 달랐다.

/keyring?pstNo=123

pstNo는 특정 콘텐츠를 고르는 화면 상태다. 같은 값을 다시 보내도 같은 결과가 나와야 한다. 재사용을 막는 자격증명이 아니다. 이 경우 URL 상태로 두는 것이 자연스럽다.

router.push(`/keyring?pstNo=${itemNo}`);

새로고침, 공유, 뒤로가기도 같은 모델 안에서 동작한다.

쿼리 같은 값 재요청 URL 상태 적합성
pstNo=123 같은 콘텐츠 조회 적합
picc_data&enc&cmac 재사용으로 거부 부적합

판단 기준

URL에 값을 둘 때 먼저 물어야 하는 질문은 하나다.

이 값을 그대로 다시 보내도 되는가?

답이 예라면 화면 상태다. 라우팅, 새로고침, 공유 링크에 실을 수 있다. 답이 아니라면 자격증명이다. 진입 요청에서만 소모하고, 이후 화면 전환은 다른 상태 모델로 내려야 한다.

상태 쿼리
  -> 재요청 가능
  -> URL에 유지
  -> history와 공유 가능

자격증명 쿼리
  -> 재요청 불가
  -> 서버 진입에서 소모
  -> 내부 상태로 화면 전환

정리

NFC 태그가 여는 URL은 단순 링크일 수도 있고, 일회성 자격증명일 수도 있다. 둘을 같은 라우팅 상태로 다루면 문제가 생긴다. ?pstNo=123은 URL에 남겨도 된다. 같은 값을 다시 보내도 같은 콘텐츠를 고르는 상태이기 때문이다. ?picc_data=...&enc=...&cmac=...는 URL에 계속 붙이면 안 된다. 같은 값을 다시 보내는 순간 재사용 방지 모델과 충돌한다. 라우팅 전략은 프레임워크 취향보다 쿼리의 의미가 먼저 정한다. URL에 담긴 값이 상태인지 자격증명인지부터 나눠야 한다.

참고

관련 글