Spring MVC / Spring Bootの@PathVariableでFormat Preserving Encryptionする

Xで次のポストを見かけ、面白そうだったので試してみました。

AES-FFXは、Format-Preserving Encryption(形式保持暗号化、FPE)の一種で、暗号化後もデータの形式(フォーマット)を保持する暗号化方式です。
例えば、10桁の数字を暗号化しても、暗号化後も10桁の数字として出力されます。

元々の話は、データベースの主キーに使うのは UUID と連番のどちらが良いかという話題でした。UUID(v7)や ULID はソートされていますが、空間効率を考えると連番に軍配が上がります。ID を連番にすると、URL に公開する際に次の ID が推測されやすいという懸念があります。

主キーと公開用のキーを別々のカラムに持つという方法もありますが、FPE などを使えば、カラムを増やす必要なく、内部用の ID を推測しづらい形で公開できるというのが元ポストの趣旨かと思われます。

いずれにせよ、Spring MVC で実装しようとすると次のようなコードをまずは考えるのではないでしょうか?

@GetMapping(path = "/customers/{customerId}")
public Customer getCustomer(@PathVariable("customerId") Long publicId) {
  Long customerId = convert(publicId);
  // ...
}

これでも間違ってはいないのですが、Long ↔ Long を都度手動で変換していると、変換漏れが発生したり、どちらの Long を使っているか混乱が起こると思われます。外に見せる ID は Spring MVC に入ってきた段階で内部 ID に変換され、アプリケーションコード上は内部 ID のみを扱うのが安全なので、この変換は Spring MVC 側に任せたいと思います。

以下では Java のライブラリ (https://github.com/mysto/java-fpe) があり、実装が容易な FF3-1 アルゴリズムを使用します。

Warning

NIST の 2025 年 2 月のドラフト 2 で、FF3 は NIST 標準から撤回されました。
他のアルゴリズムを使う場合でも今回の記事と同じ方法が使えます。

次の dependency を追加します。

        <dependency>
            <groupId>io.github.mysto</groupId>
            <artifactId>ff3</artifactId>
            <version>1.2.0</version>
        </dependency>

FF3 用の Cipher を Bean 定義する JavaConfig を次のように作ります。ここではkeytweakがハードコードされていますが、実際にはプロパティなど、外部から取得するようにしてください。

import com.privacylogistics.FF3Cipher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class CipherConfig {

    @Bean
    FF3Cipher ff3Cipher() {
        return new FF3Cipher("2DE79D232DF5585D68CE47882AE256D6", "CBD09280979564");
    }
}

Spring MVC では URL のパスパラメータのStringから@PathVariableで指定したオブジェクトに変換する際にorg.springframework.core.convert.converter.Converterが使用されます。このConverter内で公開用の ID から内部用の ID に変換すれば良いです。StringからLongConverterを作ってしまうと、影響範囲が大きくなってしまうので、ここでは ID 用のクラス(CustomerId)を作り、StringからCustomerIdConverterを作ることにします。

Tip

1つのStringを引数にもつコンストラクタや、valueOf(String)of(String)from(String)の static ファクトリメソッドを持つクラスへの変換はConverterを実装しなくても自動で変換されます。

次のような Controller を作ります。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.atomic.AtomicLong;

@RestController
public class CustomerController {
    private static final Logger logger = LoggerFactory.getLogger(CustomerController.class);
    private final AtomicLong counter = new AtomicLong(0);

    @PostMapping(path = "/customers")
    public Customer createCustomer() {
        return new Customer(new CustomerId(counter.incrementAndGet()));
    }

    @GetMapping(path = "/customers/{customerId}")
    public Customer getCustomer(@PathVariable CustomerId customerId) {
        Customer customer = new Customer(customerId);
        logger.info("Customer ID: {}", customer.id());
        return customer;
    }
}

顧客を新規作成する度に、ID が連番で払い出されます。

CustomerId は次の実装です。10 桁の数値として FF3 で暗号化することとします。

import com.privacylogistics.FF3Cipher;

public record CustomerId(long value) {

    public long encrypt(FF3Cipher cipher) {
        String formatted = String.format("%010d", value);
        try {
            String encrypted = cipher.encrypt(formatted);
            return Long.parseLong(encrypted);
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }

    public static CustomerId decrypt(FF3Cipher cipher, String encrypted) {
        try {
            return new CustomerId(Integer.parseInt(cipher.decrypt(encrypted)));
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }
}

Customer の実装はシンプルです。

public record Customer(CustomerId id) {
}

そして、Converter の実装です。

import com.privacylogistics.FF3Cipher;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

@Component
public class CustomerIdConverter implements Converter<String, CustomerId> {
    private final FF3Cipher cipher;

    public CustomerIdConverter(FF3Cipher cipher) {
        this.cipher = cipher;
    }

    @Override
    public CustomerId convert(String source) {
        return CustomerId.decrypt(this.cipher, source);
    }
}

Spring Boot では Converter 実装クラスを Bean 定義すれば、自動で変換処理に追加されます。

これで @PathVariable で復号済みの内部 ID を直接受け取ることができます。

ついでに、この Customer クラスを JSON シリアライズ/デシリアライズする際にも、FF3 で暗号化・復号されるようにシリアライザとデシリアライザも用意します。次のコードは Jackson 3 を使った例です。

import com.privacylogistics.FF3Cipher;
import org.springframework.boot.jackson.JacksonComponent;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;

@JacksonComponent
public class CustomerIdSerializer extends ValueSerializer<CustomerId> {
    private final FF3Cipher cipher;

    public CustomerIdSerializer(FF3Cipher cipher) {
        this.cipher = cipher;
    }

    @Override
    public void serialize(CustomerId value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException {
        gen.writeNumber(value.encrypt(cipher));
    }
}
import com.privacylogistics.FF3Cipher;
import org.springframework.boot.jackson.JacksonComponent;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;

@JacksonComponent
public class CustomerIdDeserializer extends ValueDeserializer<CustomerId> {
    private final FF3Cipher cipher;

    public CustomerIdDeserializer(FF3Cipher cipher) {
        this.cipher = cipher;
    }

    @Override
    public CustomerId deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
        return CustomerId.decrypt(this.cipher, p.readValueAs(String.class));
    }
}

@JacksonComponent アノテーションを使うと、自動で Jackson の Module に登録されます。

これで準備ができたので、アプリケーションを起動し、数回リクエストを送ってみます。

curl -s http://localhost:8080/customers -XPOST
curl -s http://localhost:8080/customers -XPOST
curl -s http://localhost:8080/customers -XPOST

次のレスポンスが返ります。

{"id":6413069952}
{"id":5812783830}
{"id":1827763417}

ID は 1 から連番で発行されるはずですが、レスポンスには期待通り、ぱっと見連番だとはわからない数値が含まれています。

では、この ID を使って情報を取得します。

curl -s http://localhost:8080/customers/6413069952
curl -s http://localhost:8080/customers/5812783830
curl -s http://localhost:8080/customers/1827763417

レスポンスは期待通り、パスパラメータと同じ ID が含まれるでしょう。

{"id":6413069952}
{"id":5812783830}
{"id":1827763417}

ログを確認すると、

 Customer ID: CustomerId[value=1]
 Customer ID: CustomerId[value=2]
 Customer ID: CustomerId[value=3]

と出力されているので、内部では連番 ID として扱われていることがわかります。


@PathVariable でFPEを使い、公開用のIDから内部用のIDへ自動変換する方法を紹介しました。

Note

撤回されたFF 3ではなく、FF 1を使いたい場合はこちらのライブラリが参考になりそうですが、Maven Repositorsegmentに公開されていないようです。