cloudflare / voprf-ts

A TypeScript library for Oblivious Pseudorandom Functions
BSD 3-Clause "New" or "Revised" License
28 stars 12 forks source link

feat(facade): create facade #47

Closed sublimator closed 1 year ago

sublimator commented 1 year ago

(ChatGPT ... )

Examples Types Impl

The Evolution of the OprfApi: A Story of Type Safety and Flexibility

Introduction

In the world of cryptography, type safety and flexibility are often at odds. A rigid type system can provide a strong contract, but it can also make the system difficult to extend or adapt. On the other hand, a flexible system can be easier to work with, but it can also introduce vulnerabilities or bugs. Let's explore how the OprfApi transitioned from a more rigid structure to a more flexible and type-safe design, improving both developer experience and security.

The Original API: Explicit but Cumbersome

The original was designed to be explicit, requiring the developers to manually manage various aspects like the cryptographic suite and the mode of operation. Here's how it looked:

// Original API example
export async function oprfExample() {
    const suite = Oprf.Suite.P521_SHA512
    const privateKey = await randomPrivateKey(suite)

    const server = new OPRFServer(suite, privateKey)
    const client = new OPRFClient(suite)
    // ... rest of the code
}

While this design made it clear what the developer was working with, it also made the API somewhat cumbersome. For example, each new client and server instance had to be explicitly created with the correct suite.

The Proposed API: A Step Toward Abstraction

The proposed API aimed to reduce this rigidity by abstracting away some of the details:

const OprfNoble = Oprf.withConfig({ cryptoProvider: CryptoNoble });
const suite = OprfNoble.makeSuite({mode: Oprf.Mode.OPRF, suite: Oprf.Suite.RISTRETTO255_SHA512});
const { privateKey, publicKey } = suite.generateKeyPair();
const server = suite.makeServer(privateKey);
const client = suite.makeClient(publicKey);

However, this proposed approach ran into a problem: creating a generic makeClient interface became challenging due to the need to make the public key optional, which was not ideal for type safety.

The Final API: Type-Safe and Intuitive

The final version of the API solves these issues by leveraging TypeScript's conditional types and mapped types, making the API more flexible and type-safe at the same time.

export interface Client<M extends ModeID = ModeID, S extends SuiteID = SuiteID>
    extends Modal<M, S> {
    finalize: M extends MODEMAP['poprf']
        ? (
              finData: FinalizeData,
              evaluation: Evaluation,
              info?: Uint8Array
          ) => Promise<Array<Uint8Array>>
        : (finData: FinalizeData, evaluation: Evaluation) => Promise<Array<Uint8Array>>
}

The use of conditional types allows the Client interface to have different method signatures based on the mode of operation (M). This means that the finalize method can have different argument types and return types based on whether the mode is poprf or something else, without compromising type safety.

We did experiment with simply using advanced features to automatically append arguments, but then the IDE experience was not as nice, showing only generic argument names.

Why the CryptoProvider?

In the final API, the CryptoProvider is part of the OprfApi. This is an important design choice because it allows developers to inject their own cryptographic libraries or functionalities, making the API adaptable to different environments or constraints.

export interface OprfApi {
    readonly crypto: CryptoProvider;
    withConfig(config: { crypto: CryptoProvider }): OprfApi;
}

Initially, only one configuration could be allowed at a time, with runtime failure when an "old" OprfApi is used, but with some refactoring it will be possible to support more than one at a time.

No More Passing Around suiteID and modeID

One of the most significant improvements is that developers no longer need to pass around suiteID and modeID explicitly. Instead, they can simply create a Mode object:

makeMode<M extends ModeID, S extends SuiteID>(params: { mode: M; suite: S }): Mode<M, S>

Once this object is created, it knows how to operate on keys and create client and server instances, encapsulating the suite and mode information within itself.

Compare signatures:

export async function derivePrivateKey(
    mode: ModeID,
    id: SuiteID,
    seed: Uint8Array,
    info: Uint8Array
): Promise<Uint8Array> {

vs

derivePrivateKey(seed: Uint8Array, info: Uint8Array): Promise<Uint8Array>

Conclusion

By leveraging TypeScript's advanced type features, the API still provides a robust contract for developers while also being easier and more intuitive to use.


The Intermediary Pattern: A Flexible Yet Constrained Crypto Provider

At the heart of the cryptographic operations within the library is the CryptoProviderIntermediary class. This class serves as a flexible conduit that conforms to the CryptoProvider interface. It encapsulates an actual CryptoProvider instance, allowing the underlying provider to be switched at runtime.

// Example usage
import { CryptoImpl } from '@cloudflare/voprf-ts';
import { YourCryptoProvider } from './your-crypto-provider-file.js';

// Override the default crypto provider
CryptoImpl.provider = YourCryptoProvider;

// CryptoImpl now delegates to YourCryptoProvider

However, this flexibility comes with a caveat. All instances of OprfApi share this singleton CryptoProvider via CryptoImpl.provider, constraining them to use the same cryptographic provider at any given time.

By understanding this intermediary design pattern, developers can easily switch between cryptographic providers for all instances of OprfApi. Yet they should also be cautious, as changing the provider in one place will affect it globally across all OprfApi instances, due to the current reliance on the singleton pattern.


Implementation Details: Safeguarding Against Inconsistent Crypto Providers

The Challenge of Supporting Multiple Crypto Providers

The eventual goal of the OprfApi is to support multiple instances, each configured with a different CryptoProvider. However, the current implementation uses a facade pattern that wraps around the existing internals, which rely on a singleton pattern for accessing the cryptographic provider via CryptoImpl.provider. This creates a challenge: How do you prevent developers from inadvertently using multiple instances of OprfApi with different crypto providers, given that they would actually all use the same underlying provider?

Runtime Checks: A Temporary Safeguard

To mitigate this risk, the OprfApi implementation includes a runtime check to ensure that all instances are using the expected cryptographic provider. This is done using the errorIfCryptoChanged function, which wraps all methods in the API that rely on cryptographic operations.

Here's a simplified version of how it works:

export function errorIfCryptoChanged<T>(
    bind: string,
    val: T,
    keys: AllFuncKeys<T>
) {
    // Runtime check
    if (bind !== CryptoImpl.name) {
        throw new Error('Currently only one supported CryptoProvider at a time');
    }
    // ... (rest of the wrapping logic)
}

By applying this function to wrap all relevant methods, the API ensures that if the CryptoImpl.provider changes, an error is thrown to alert the developer. This effectively prevents a situation where different instances of OprfApi might be assumed to use different crypto providers but would actually all revert to the same one, thereby causing inconsistencies or even security vulnerabilities.

Toward a More Flexible Future

This runtime check is a temporary measure, meant to be in place until the internals of the OprfApi can be refactored to genuinely support multiple crypto providers. Once that is done, each instance of OprfApi will be able to operate independently with its own CryptoProvider, fulfilling the promise of both flexibility and robustness.

Conclusion

The use of runtime checks to enforce consistency in the cryptographic provider serves as a cautionary safeguard, ensuring that developers are aware of the current limitations while also signaling the intent for future flexibility. This approach maintains the integrity and security of the application, even as it evolves to offer more configurability.