0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

請求書の改ざん検出で、ハッシュを同じドキュメントに保存してはいけない

0
Posted at

Next.js + Firestore で請求書を発行する機能を作っていて、「発行済みの請求書が後から書き換えられていないこと」をどう保証するかで一度詰まりました。

素朴に実装するとまったく意味のない改ざん検出ができあがります。自分がそれを書いたので、その話を残しておきます。

何が問題か

請求書は発行したら内容が変わってはいけない書類です。特に適格請求書(インボイス)として扱う場合、金額・税額・登録番号が後から書き換わると困ります。

Firestore は Admin SDK からなら何でも書けます。アプリケーションのバグでも、運用オペレーションのミスでも、権限を持った内部の人間でも、invoices/{invoiceNumber}amount_jpy を書き換えられます。

なので「書き換えを防ぐ」のではなく、まず「書き換えられたことを検出する」ところから始めました。

素朴な実装と、その穴

最初に書いたのはこれです。

// 請求書の主要フィールドからハッシュを作る
const hash = createHash("sha256")
  .update(`${amount_jpy}\n${tax_jpy}\n${registration_number}`)
  .digest("hex");

await db.collection("invoices").doc(invoiceNumber).set({
  amount_jpy,
  tax_jpy,
  registration_number,
  content_hash: hash,   // ← 同じドキュメントに保存
});

読み出すときに content_hash を再計算して比較すれば、改ざんが検出できる。そう思っていました。

できません。

amount_jpy を書き換えられる人は、同じドキュメントの content_hash も書き換えられます。両方を整合させて更新すれば、検証は必ず通ります。

// 攻撃者(あるいは壊れたバッチ処理)がやること
await ref.update({
  amount_jpy: 1,
  content_hash: recompute({ amount_jpy: 1, ... }),  // 一緒に更新するだけ
});

自分のハッシュを自分で持っているデータは、自己証明にはなりません。検証者と被検証者が同一であるという、よく考えれば当たり前の話です。

独立したコレクションに二重で書く

そこで、ハッシュを 別のトップレベルコレクション に置きました。

const batch = db.batch();

// 1. 請求書本体
batch.create(db.collection("invoices").doc(invoiceNumber), {
  ...record,
  content_hash: contentHash,
});

// 2. 独立した audit レコード(こちらを「真」とする)
batch.create(db.collection("invoice_audit").doc(invoiceNumber), {
  invoice_number: invoiceNumber,
  tenant_id: record.tenant_id,
  created_at: record.created_at,
  content_hash: contentHash,
});

await batch.commit();

ポイントは3つあります。

invoice_audit は Security Rules で完全に閉じる

match /invoice_audit/{invoiceNumber} {
  allow read, write: if false;
}

if false にすると、クライアント SDK からは読むことも書くこともできなくなります。Admin SDK は Security Rules を迂回するので、サーバー側からだけ書き込めます。

これで「アプリのクライアントコードのどこかから誤って更新される」経路が消えます。

create を使う(set ではない)

batch.create() は、ドキュメントが既に存在するとエラーになります。set() だと黙って上書きされます。

audit レコードは一度書いたら二度と変わらないはずのものなので、create で「上書きは異常である」ことをコードに埋め込んでおきます。

同じ batch に入れる

本体と audit を別々に書くと、片方だけ成功した状態が生まれます。

  • 本体だけ書けた → audit がないので検証をすり抜ける
  • audit だけ書けた → 本体がない請求書番号が生まれる

Firestore の WriteBatch は atomic なので、同じ batch に入れておけば両方成功か両方失敗のどちらかになります。

実際のコードではもう1件、Stripe の webhook 再送で同じ請求書が二重発行されるのを防ぐ dedup 用のドキュメントも同じ batch に入れています。「1回の発行で書かれるべきものは全部同じ batch」に寄せておくと、片方だけ残った中途半端な状態を後から掃除する処理が要らなくなります。

ハッシュに何を含めるか

金額だけでは足りません。発行者・受領者・明細・登録番号まで含めないと、「金額はそのままで宛名だけ差し替える」が検出できません。

export function computeInvoiceContentHash(rec: Invoice): string {
  const parts: string[] = [
    `invoice_number=${rec.invoice_number}`,
    `tenant_id=${rec.tenant_id}`,
    `amount_jpy=${rec.amount_jpy}`,
    `tax_jpy=${rec.tax_jpy}`,
    `registration_number=${rec.registration_number}`,
    `issued_at=${rec.issued_at}`,
    `issuer=${JSON.stringify({
      name: rec.issuer?.name ?? "",
      registration_number: rec.issuer?.registration_number ?? "",
      address: rec.issuer?.address ?? "",
      email: rec.issuer?.email ?? "",
      tel: rec.issuer?.tel ?? "",
    })}`,
    `recipient=${JSON.stringify({ /* 同様 */ })}`,
    `line_items=${JSON.stringify(
      (rec.line_items ?? []).map((l) => ({
        description: l.description,
        amount_excluding_tax: l.amount_excluding_tax,
        tax_rate: l.tax_rate,
        tax_amount: l.tax_amount,
        tax_inferred: l.tax_inferred === true,
      })),
    )}`,
  ];
  return createHash("sha256").update(parts.join("\n")).digest("hex");
}

書き方で気をつけた点です。

キー名を値に含める。 ${a}\n${b} だと、a="1\n2", b="3"a="1", b="2\n3" が同じハッシュになります。amount_jpy= のようなプレフィックスを付けておくと、この種の境界の曖昧さが減ります。

オブジェクトはフィールドを明示的に列挙する。 JSON.stringify(rec.issuer) と書くと、キーの順序や後から追加されたフィールドでハッシュが変わり、既存レコードが全部「改ざん」になります。何を含めるかを固定します。

undefined と空文字を統一する。 ?? "" を挟まないと、undefined のフィールドが JSON.stringify で消えて、後から空文字が入ったときに別のハッシュになります。ブール値も同じで、l.tax_inferred === true と書いて undefined を必ず false に落としておかないと、フラグが未設定のレコードだけキーごと消えてハッシュがずれます。

検証側

PDF を生成するタイミングで照合します。

const [invSnap, auditSnap] = await Promise.all([
  db.collection("invoices").doc(invoiceNumber).get(),
  db.collection("invoice_audit").doc(invoiceNumber).get(),
]);

// 認可は audit 側の tenant_id で先に行い、通らなければ 403 で即 return する。
// invoice.tenant_id は書き換えられうるので、認可判定に使ってはいけない。
assertTenant(session, auditSnap.data()?.tenant_id);

const invoice = invSnap.data() as Invoice;
const audit = auditSnap;

if (audit.exists) {
  const a = audit.data();
  const recomputed = computeInvoiceContentHash(invoice);

  const tamperReason =
    invoice.tenant_id !== a.tenant_id     ? "tenant_id_mismatch"
    : invoice.created_at !== a.created_at ? "created_at_mismatch"
    : !invoice.content_hash               ? "missing_hash"
    : recomputed !== a.content_hash ||
      invoice.content_hash !== a.content_hash
                                          ? "hash_mismatch"
    : null;

  if (tamperReason) {
    // 監査ログは audit 側の tenant_id に書く。
    // invoice.tenant_id は書き換えられている可能性があるため。
    await recordAudit(a.tenant_id, {
      action: "invoice.tamper_detected",
      metadata: { invoice_number: invoiceNumber, reason: tamperReason, /* ... */ },
    });
    return new Response("Gone", { status: 410 });
  }
}

いくつか意図があります。

recomputedinvoice.content_hash の両方を audit と比べる。 片方だけだと、「本体のフィールドを書き換えて content_hash も辻褄を合わせた」ケースと「フィールドはそのままで content_hash だけ壊れた」ケースを取りこぼします。

理由を分類して記録する。 tenant_id_mismatch はテナント間の混線、hash_mismatch は内容の書き換え、missing_hash は audit があるのに本体の content_hash だけが消えた状態です。同じ batch で atomic に書いている以上「片方だけ書けなかった」は起きないので、missing_hash も書き換えを疑う側の分類になります。実装ではもう1つ、audit はあるのに invoices/{invoiceNumber} 自体が消えている invoice_deleted も分けています。まとめて "invalid" にすると調査できません。

audit.exists の判定を挟む理由。 audit レコードが無いのは、この仕組みを入れる前に発行された古いレコードだけです。そちらは検証をすり抜けさせるのではなく、別の条件で弾きます。「audit が無ければ素通り」にしてしまうと、audit を消すだけで検証を回避できることになります。

監査ログの宛先は audit 側の tenant_id invoice.tenant_id を信じると、書き換えた側が指定したテナントにログが書かれます。壊れているかもしれない値を、壊れたことの記録先に使ってはいけません。

返すのは 410 Gone。 404 だと「もともと無い」と区別がつきません。410 は「かつて存在したが、もう有効ではない」の意味なので、この状況に合います。

この仕組みで防げないこと

はっきりさせておきます。

  • 改ざんを防止しません。 検出するだけです。Admin SDK を持っている人は書き換えられます
  • audit ごと書き換えられたら検出できません。 同じ Firestore 内にある以上、Admin SDK からは両方書けます。防いでいるのは「本体だけを書き換える」経路と「クライアント経由の書き込み」です。本気で守るなら外部の WORM ストレージや、ブロックチェーン的な外部アンカーが要ります
  • 電子帳簿保存法や消費税法の保存要件を満たすことを保証しません。 「訂正削除の防止に関する事務処理規程」のような運用側の要件が別途あります。要件充足の判断は税理士に確認してください

「検出できる」と「防止できる」は違いますし、「技術的に検出できる」と「法令要件を満たす」はもっと違います。ここを混ぜて書いてある解説を読んで実装すると危ないので、自分の分は明示しておきます。

まとめ

  • ハッシュを検証対象と同じドキュメントに置くと、検証は成立しない
  • 独立したコレクションに二重書き込みし、そちらを「真」とする
  • そのコレクションは Security Rules で if falsecreate で書き、本体と同じ batch に入れる
  • ハッシュにはキー名を含め、オブジェクトのフィールドは明示列挙する
  • 検出と防止、技術要件と法令要件を混同しない

作ったもの

この実装は、日本の商習慣に合わせた Next.js のスターターキット StartKit JP に入れているものです。Next.js 16 / React 19 / Firebase / Stripe 構成で、適格請求書ひな形の PDF 発行、消費税計算、テナント管理などを実装済みの状態から始められます。

なお、単一税率(10%)を前提としたひな形で、軽減税率8%の識別表示や複数税率の混在には対応していません。適格請求書として運用する場合は顧問税理士にご確認ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?