クラウド Mac で検証する iOS Ed25519 署名とメッセージのバイト列

クラウド Mac で検証する iOS Ed25519 署名とメッセージのバイト列

クラウド Mac で検証する iOS Ed25519 署名とメッセージのバイト列

タイムゾーンの異なるチーム間で署名設定を引き継ぐ際、「サーバー側では署名に成功するのに、iOS では検証に失敗する」という問題がよく起こります。双方が表示する JSON は同じで、鍵を再生成しても改善しません。まずクラウド Mac 上で業務 API に依存しない署名検証の基準を確立し、そのうえで元のバイト列を照合することで、暗号 API の呼び出し、転送データのデコード、メッセージの再構築のどこに問題があるかを切り分けられます。ここでは Swift と CryptoKit を使い、公開テストデータだけを扱います。本番環境の秘密鍵は必要ありません。

アルゴリズムと入力の取り決めを先に固定する

使用するアルゴリズムは通常の Ed25519 とし、公開鍵は 32 バイト、署名は 64 バイトの生データ形式とします。CryptoKit では Curve25519.Signing.PublicKey が対応します。名前が似ているからといって、鍵共有用の型に置き換えてはいけません。

署名側は、レスポンスボディの元のバイト列に署名するのか、何らかの正規化処理を施した結果に署名するのかを明示する必要があります。元のボディに署名する場合、クライアントでは解析前に署名を検証します。JSON を解析してから再エンコードしたデータで検証してはいけません。正規化を行う取り決めであれば、ルールのバージョンとテストサンプルを固定します。

Ed25519 署名が証明するのは、メッセージが信頼済みの公開鍵と整合することです。機密性は提供せず、リプレイ攻撃も自動的には防ぎません。公開鍵が、差し替え可能な同じレスポンスに含まれている場合、署名検証に成功しても、それだけでは信頼を確立できません。

通常の Ed25519 と Ed25519ph も区別する必要があります。メッセージのダイジェストを先に計算して通常の API に渡しても、事前ハッシュ型に切り替えたことにはなりません。「どちらも Ed25519 だから」という理由で、プロトコルからこれらの条件を省略してはいけません。

公開標準ベクトルで最小構成の検証を通す

開発ツールをインストール済みのクラウド Mac で xcrun swift --version を実行し、ツールチェーンのバージョンと sw_vers の出力を記録します。以下を verify.swift として保存し、xcrun swift verify.swift を実行してください。コードでは RFC 8032 の空メッセージ用テストベクトルを使用します。この公開された 16 進数データは、業務用の鍵ではありません。

import Foundation
import CryptoKit

enum FixtureError: Error {
    case invalidHex
    case checkFailed
}

func hex(_ text: String) throws -> Data {
    let chars = Array(text.utf8)
    guard chars.count % 2 == 0 else {
        throw FixtureError.invalidHex
    }
    var bytes = [UInt8]()
    for offset in stride(from: 0, to: chars.count, by: 2) {
        let pair = String(
            decoding: chars[offset..<(offset + 2)],
            as: UTF8.self
        )
        guard let value = UInt8(pair, radix: 16) else {
            throw FixtureError.invalidHex
        }
        bytes.append(value)
    }
    return Data(bytes)
}

func require(_ value: Bool) throws {
    guard value else {
        throw FixtureError.checkFailed
    }
}

let publicBytes = try hex(
    "d75a980182b10ab7d54bfed3c964073a0" +
    "ee172f3daa62325af021a68f707511a"
)
let signature = try hex(
    "e5564300c360ac729086e2cc806e828a8" +
    "4877f1eb8e5d974d873e0652249015555" +
    "5fb8821590a33bacc61e39701cf9b46bd" +
    "25bf5f0595bbe24655141438e7a100b"
)
try require(publicBytes.count == 32)
try require(signature.count == 64)

let key = try Curve25519.Signing.PublicKey(
    rawRepresentation: publicBytes
)
try require(key.isValidSignature(signature, for: Data()))
try require(!key.isValidSignature(
    signature, for: Data([0x00])
))

var altered = signature
altered[altered.startIndex] ^= 1
try require(!key.isValidSignature(altered, for: Data()))
try require(!key.isValidSignature(
    Data(signature.dropLast()), for: Data()
))

var rejectedKey = false
do {
    _ = try Curve25519.Signing.PublicKey(
        rawRepresentation: Data(publicBytes.dropLast())
    )
} catch {
    rejectedKey = true
}
try require(rejectedKey)

スクリプトは成功時には何も出力せず、失敗時にはエラーをスローしてゼロ以外の終了ステータスで終了します。ここでは assert ではなく、エラーをスローするチェックを使っています。最適化コンパイルでアサーションが取り除かれるのを避けるためです。まずこの固定データで成功することを確認し、その後に業務用サンプルを組み込みます。ネットワークと暗号 API を同時に調べると、切り分けるべき要因が増えてしまいます。

テキストの違いをバイト単位で突き止める

署名前のメッセージファイルと、クライアントが実際に受信したファイルをそれぞれ保存します。公開フィクスチャであれば cmp で比較し、続いて xxd -g 1 で最初の差分を調べられます。本番のボディには機密情報が含まれる可能性があるため、そのまま共有ログへ出力してはいけません。

まず、エディタによって変更されやすい箇所から確認します。末尾に改行が追加されていないか、改行コードは LF か CRLF か、ファイルの先頭に UTF-8 BOM が付いていないか、文字に Unicode 正規化が行われていないかを調べます。見た目が同じアクセント付き文字でも、バイト列が異なる場合があります。

ファイルを作る際、安易に echo を使わないでください。改行が追加される可能性があります。メッセージはファイルの生データとして読み取り、いったん文字列へ変換してからデータに戻す処理は避けます。プロトコルが文字列への署名を定めている場合は UTF-8 を明示し、末尾の改行、空メッセージ、結合文字について、それぞれフィクスチャを用意します。

転送用の形式を個別に検証する

API が Base64 でデータを渡す場合は、まずデコードしてから長さを確認します。16 進数の 128 文字は、128 バイトの署名ではありません。また、生の公開鍵とヘッダー情報を含む鍵ファイルは別物です。ラップされたデータ全体を、そのまま rawRepresentation に渡してはいけません。

デコードに失敗した際、空データに置き換えて検証を続けることは禁止します。内部では「転送形式が不正」「長さが不正」「署名が一致しない」を別々のエラー型として扱い、外部には不要な解析の詳細を公開しないようにします。

組み込みの誤りを検出できる異常系ケースを用意する

最小限のテストケースには、正しいメッセージと正しい署名、メッセージへのゼロバイトの追加、署名の 1 ビット反転、署名の切り詰め、公開鍵の切り詰め、別の有効な公開鍵による元の署名の検証を含めます。最後のケースには、実際に有効な別の公開鍵を使う必要があります。壊れた公開鍵で初期化エラーを起こすだけでは不十分です。

業務層では、「ボディは正しいが、署名は直前のメッセージのもの」というサンプルも追加し、コードがキャッシュ済みの結果を誤って再利用していないか確認します。署名対象にバージョン、有効期限、用途識別子が含まれる場合は、それらのフィールドが署名対象のバイト列に実際に含まれていることも検証します。署名されない外側のオブジェクトに置くだけではいけません。

これらのテストで確かめるのは、入力を拒否する処理経路です。固定フィクスチャの代わりに、データをランダムに破損させてはいけません。同じ固定サンプルを使うことで、異なるノードや勤務シフトのメンバーが、同じ失敗を再現できます。

証跡のチェックリストでタイムゾーンをまたぐ引き継ぎを完了する

引き継ぎ時には、コミット ID、OS とツールチェーンのバージョン、アルゴリズムの種類、公開鍵の識別子、メッセージのバイト数、署名のバイト数、各テストケースの結果を記録します。公開フィクスチャは一緒に保存できます。実際の業務メッセージは機密度に応じて管理・保管し、署名検証ではダイジェストを元のメッセージの代わりに使わないようにします。

コマンドラインでの基準テストが通ったら、同じフィクスチャを iOS のテストターゲットに追加し、アプリが実際に使用する署名検証ラッパーを呼び出します。これにより、CryptoKit 自体が動くことの確認にとどまらず、Base64 のデコード、公開鍵の選択、メッセージの読み取り経路まで検証できます。

完了の基準は「一度検証できた」ことではありません。正しい入力が安定して成功し、すべての異常系ケースが明確に失敗し、署名対象のバイト列について両端の解釈に曖昧さがなく、別シフトのメンバーが秘密鍵なしで全チェックを再実行できることが基準です。

よくある質問

クライアント側の検証にも秘密鍵が必要ですか?

不要です。信頼できる公開鍵、元のメッセージ、署名があれば検証できます。公開テストベクトルは保存できますが、本番の秘密鍵を検証環境に渡す必要はありません。

JSON の内容が同じなのに検証が失敗するのはなぜですか?

Ed25519 は JSON の意味ではなくバイト列を検証します。キー順、空白、改行、Unicode 表現が違えば別のメッセージです。受信した元のバイト列を使うか、共通の正規化方式を定義してください。

次のタスクに最適なクラウドMacを選ぶ

チームの主な所在地に近いリージョンを選び、Mac miniを専有で利用できます。ブラウザーからのリモートデスクトップには、最新のブラウザーと安定したネットワークが必要です。構成、課金期間、実際の空き状況は注文前にご確認ください。

次の接続先は近くのリージョンに

まず構成と利用期間を確認し、チームが主に利用する地域に近いリージョンを選びましょう。