Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion apps/mobile/src/features/cloud/dpop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,24 @@ describe("mobile DPoP", () => {

const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);

expect(error.message).toBe("Stored DPoP proof key is invalid.");
expect(error).toMatchObject({
operation: "decode-key",
cause: expect.anything(),
});
}).pipe(Effect.provide(cryptoLayer)),
);

it.effect("reports invalid proof URLs as a structural operation failure", () =>
Effect.gen(function* () {
const proofKey = yield* generateDpopProofKeyPair();
const error = yield* createDpopProof({
method: "POST",
url: "not a URL",
proofKey,
}).pipe(Effect.flip);

expect(error).toMatchObject({ operation: "normalize-proof-url" });
expect(error.cause).toBeUndefined();
}).pipe(Effect.provide(cryptoLayer)),
);

Expand Down
100 changes: 67 additions & 33 deletions apps/mobile/src/features/cloud/dpop.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as Clock from "effect/Clock";
import * as Crypto from "effect/Crypto";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
import * as Result from "effect/Result";
Expand All @@ -16,13 +15,53 @@ import {
} from "@t3tools/shared/dpop";
import * as Layer from "effect/Layer";

export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
export class CloudDpopError extends Schema.TaggedErrorClass<CloudDpopError>()("CloudDpopError", {
operation: Schema.Literals([
"generate-key-randomness",
"derive-public-key",
"read-key",
"decode-key",
"validate-key",
"encode-key",
"store-key",
"normalize-proof-url",
"import-private-key",
"generate-proof-id",
"encode-proof-header",
"encode-proof-payload",
"hash-signing-input",
"sign-proof",
]),
cause: Schema.optional(Schema.Defect()),
}) {
override get message(): string {
return `Cloud DPoP operation "${this.operation}" failed.`;
}
}

function cloudDpopError(message: string) {
return (cause: unknown) => new CloudDpopError({ message, cause });
export class DpopPublicKeyFormatError extends Schema.TaggedErrorClass<DpopPublicKeyFormatError>()(
"DpopPublicKeyFormatError",
{
byteLength: Schema.Number,
firstByte: Schema.optional(Schema.Number),
},
) {
override get message(): string {
const prefix =
this.firstByte === undefined
? "no prefix byte"
: `prefix 0x${this.firstByte.toString(16).padStart(2, "0")}`;
return `Expected a 65-byte uncompressed P-256 public key beginning with 0x04; received ${this.byteLength} bytes with ${prefix}.`;
}
}

export class DpopStoredPublicKeyMismatchError extends Schema.TaggedErrorClass<DpopStoredPublicKeyMismatchError>()(
"DpopStoredPublicKeyMismatchError",
{},
) {
override get message(): string {
return "Stored DPoP private and public key material do not match.";
}
}

const DpopPrivateJwkSchema = Schema.Struct({
Expand Down Expand Up @@ -97,29 +136,28 @@ function base64UrlToBytes(value: string): Uint8Array {
return Result.getOrThrow(Encoding.decodeBase64Url(value));
}

function sha256Digest(
data: Uint8Array,
message: string,
): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
function sha256Digest(data: Uint8Array): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
return Crypto.Crypto.pipe(
Effect.flatMap((crypto) => crypto.digest("SHA-256", data)),
Effect.mapError(cloudDpopError(message)),
Effect.mapError((cause) => new CloudDpopError({ operation: "hash-signing-input", cause })),
);
}

function secureRandomBytes(
byteCount: number,
message: string,
): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
return Crypto.Crypto.pipe(
Effect.flatMap((crypto) => crypto.randomBytes(byteCount)),
Effect.mapError(cloudDpopError(message)),
Effect.mapError((cause) => new CloudDpopError({ operation: "generate-key-randomness", cause })),
);
}

function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {
if (publicKey.length !== 65 || publicKey[0] !== 0x04) {
throw new Error("Generated DPoP public key is not an uncompressed P-256 point.");
throw new DpopPublicKeyFormatError({
byteLength: publicKey.length,
...(publicKey[0] === undefined ? {} : { firstByte: publicKey[0] }),
});
}
return {
kty: "EC",
Expand All @@ -144,14 +182,11 @@ export function generateDpopProofKeyPair(): Effect.Effect<
return Effect.gen(function* () {
let privateKey: Uint8Array;
do {
privateKey = yield* secureRandomBytes(
p256.CURVE.nByteLength,
"Could not generate DPoP key pair randomness.",
);
privateKey = yield* secureRandomBytes(p256.CURVE.nByteLength);
} while (!p256.utils.isValidPrivateKey(privateKey));
const publicJwk = yield* Effect.try({
try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
catch: cloudDpopError("Generated DPoP public key is invalid."),
catch: (cause) => new CloudDpopError({ operation: "derive-public-key", cause }),
});
const thumbprint = computeDpopJwkThumbprint(publicJwk);
return {
Expand All @@ -170,11 +205,11 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect<
return Effect.gen(function* () {
const stored = yield* Effect.tryPromise({
try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY),
catch: cloudDpopError("Could not read the DPoP proof key."),
catch: (cause) => new CloudDpopError({ operation: "read-key", cause }),
});
if (stored) {
const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe(
Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")),
Effect.mapError((cause) => new CloudDpopError({ operation: "decode-key", cause })),
);
const restored = yield* Effect.try({
try: () => {
Expand All @@ -183,11 +218,11 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect<
p256.getPublicKey(privateKey, false),
);
if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) {
throw new Error("Stored DPoP key does not match its public key.");
throw new DpopStoredPublicKeyMismatchError();
}
return { privateJwk: storedPrivateJwk, publicJwk };
},
catch: cloudDpopError("Stored DPoP proof key is invalid."),
catch: (cause) => new CloudDpopError({ operation: "validate-key", cause }),
});
return {
...restored,
Expand All @@ -196,11 +231,11 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect<
}
const generated = yield* generateDpopProofKeyPair();
const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(
Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")),
Effect.mapError((cause) => new CloudDpopError({ operation: "encode-key", cause })),
);
yield* Effect.tryPromise({
try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk),
catch: cloudDpopError("Could not store the DPoP proof key."),
catch: (cause) => new CloudDpopError({ operation: "store-key", cause }),
});
return generated;
});
Expand All @@ -210,7 +245,7 @@ function normalizeHtu(url: string): Effect.Effect<string, CloudDpopError> {
const normalized = normalizeDpopHtu(url);
return normalized
? Effect.succeed(normalized)
: Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." }));
: Effect.fail(new CloudDpopError({ operation: "normalize-proof-url" }));
}

export function createDpopProof(input: {
Expand All @@ -227,12 +262,12 @@ export function createDpopProof(input: {
const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair());
const privateKey = yield* Effect.try({
try: () => base64UrlToBytes(keyPair.privateJwk.d),
catch: cloudDpopError("Could not import DPoP private key."),
catch: (cause) => new CloudDpopError({ operation: "import-private-key", cause }),
});
const nowMs = yield* Clock.currentTimeMillis;
const jti = yield* Crypto.Crypto.pipe(
Effect.flatMap((crypto) => crypto.randomUUIDv4),
Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")),
Effect.mapError((cause) => new CloudDpopError({ operation: "generate-proof-id", cause })),
);
const htu = yield* normalizeHtu(input.url);
const header = yield* encodeDpopJwtHeaderJson({
Expand All @@ -241,7 +276,7 @@ export function createDpopProof(input: {
jwk: keyPair.publicJwk,
}).pipe(
Effect.map(Encoding.encodeBase64Url),
Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
Effect.mapError((cause) => new CloudDpopError({ operation: "encode-proof-header", cause })),
);
const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;
const payload = yield* encodeDpopJwtPayloadJson({
Expand All @@ -252,15 +287,14 @@ export function createDpopProof(input: {
...(ath ? { ath } : {}),
}).pipe(
Effect.map(Encoding.encodeBase64Url),
Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")),
Effect.mapError((cause) => new CloudDpopError({ operation: "encode-proof-payload", cause })),
);
const signatureInputHash = yield* sha256Digest(
new TextEncoder().encode(`${header}.${payload}`),
"Could not hash DPoP signing input.",
);
const signature = yield* Effect.try({
try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(),
catch: cloudDpopError("Could not sign DPoP proof."),
catch: (cause) => new CloudDpopError({ operation: "sign-proof", cause }),
});
return {
proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`,
Expand Down
23 changes: 21 additions & 2 deletions apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Logger from "effect/Logger";
import * as References from "effect/References";
import { vi } from "vite-plus/test";

const secureStore = vi.hoisted(() => new Map<string, string>());
Expand All @@ -16,7 +18,10 @@ vi.mock("expo-secure-store", () => ({
}),
}));

import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";
import {
isManagedRelayTokenStoreError,
managedRelayAccessTokenStore,
} from "./managedRelayTokenStore";

it.effect("round-trips and clears persisted managed relay access tokens", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -45,7 +50,21 @@ it.effect("falls back to an empty cache when persisted data is invalid", () =>
Effect.gen(function* () {
secureStore.clear();
secureStore.set("t3code.cloud.relay-access-tokens", "not-json");
const annotations: Array<Record<string, unknown>> = [];
const logger = Logger.make(({ fiber }) => {
annotations.push(fiber.getRef(References.CurrentLogAnnotations));
});

expect(yield* managedRelayAccessTokenStore.load).toEqual([]);
expect(
yield* managedRelayAccessTokenStore.load.pipe(
Effect.provide(Logger.layer([logger], { mergeWithExisting: false })),
),
).toEqual([]);
expect(annotations).toHaveLength(1);
expect(isManagedRelayTokenStoreError(annotations[0]?.error)).toBe(true);
expect(annotations[0]?.error).toMatchObject({
operation: "decode-cache",
cause: expect.anything(),
});
}),
);
66 changes: 38 additions & 28 deletions apps/mobile/src/features/cloud/managedRelayTokenStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ManagedRelay } from "@t3tools/client-runtime/relay";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as SecureStore from "expo-secure-store";
Expand Down Expand Up @@ -31,36 +30,48 @@ const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect(
);
const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema);

export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{
readonly message: string;
readonly cause: unknown;
}> {}
export class ManagedRelayTokenStoreError extends Schema.TaggedErrorClass<ManagedRelayTokenStoreError>()(
"ManagedRelayTokenStoreError",
{
operation: Schema.Literals([
"read-cache",
"decode-cache",
"encode-cache",
"write-cache",
"clear-cache",
]),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Managed relay token store operation "${this.operation}" failed.`;
}
}

const storeError =
(message: string) =>
(cause: unknown): ManagedRelayTokenStoreError =>
new ManagedRelayTokenStoreError({ message, cause });
export const isManagedRelayTokenStoreError = Schema.is(ManagedRelayTokenStoreError);

function logStoreFailure(operation: string) {
return (error: ManagedRelayTokenStoreError) =>
Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe(
Effect.annotateLogs({
errorTag: error._tag,
message: error.message,
}),
);
function logStoreFailure(error: ManagedRelayTokenStoreError) {
return Effect.logWarning(error.message).pipe(
Effect.annotateLogs({
error,
errorTag: error._tag,
operation: error.operation,
}),
);
}

const loadManagedRelayAccessTokens = Effect.tryPromise({
try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),
catch: storeError("Could not read persisted relay access tokens."),
catch: (cause) => new ManagedRelayTokenStoreError({ operation: "read-cache", cause }),
}).pipe(
Effect.flatMap((encoded) =>
encoded === null
? Effect.succeed<ReadonlyArray<ManagedRelay.ManagedRelayAccessTokenCacheEntry>>([])
: decodeManagedRelayAccessTokenCache(encoded).pipe(
Effect.map((cache) => cache.entries),
Effect.mapError(storeError("Persisted relay access tokens are invalid.")),
Effect.mapError(
(cause) => new ManagedRelayTokenStoreError({ operation: "decode-cache", cause }),
),
),
),
);
Expand All @@ -72,34 +83,33 @@ const saveManagedRelayAccessTokens = (
version: MANAGED_RELAY_TOKEN_CACHE_VERSION,
entries,
}).pipe(
Effect.mapError(storeError("Could not encode relay access tokens.")),
Effect.mapError(
(cause) => new ManagedRelayTokenStoreError({ operation: "encode-cache", cause }),
),
Effect.flatMap((encoded) =>
Effect.tryPromise({
try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded),
catch: storeError("Could not persist relay access tokens."),
catch: (cause) => new ManagedRelayTokenStoreError({ operation: "write-cache", cause }),
}),
),
);

const clearManagedRelayAccessTokens = Effect.tryPromise({
try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY),
catch: storeError("Could not clear persisted relay access tokens."),
catch: (cause) => new ManagedRelayTokenStoreError({ operation: "clear-cache", cause }),
});

export const managedRelayAccessTokenStore: ManagedRelay.ManagedRelayAccessTokenStore = {
load: loadManagedRelayAccessTokens.pipe(
Effect.tapError(logStoreFailure("load")),
Effect.tapError(logStoreFailure),
Effect.orElseSucceed(() => []),
Effect.withSpan("mobile.managedRelayTokenStore.load"),
),
save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) =>
saveManagedRelayAccessTokens(entries).pipe(
Effect.tapError(logStoreFailure("save")),
Effect.ignore,
),
saveManagedRelayAccessTokens(entries).pipe(Effect.tapError(logStoreFailure), Effect.ignore),
),
clear: clearManagedRelayAccessTokens.pipe(
Effect.tapError(logStoreFailure("clear")),
Effect.tapError(logStoreFailure),
Effect.ignore,
Effect.withSpan("mobile.managedRelayTokenStore.clear"),
),
Expand Down
Loading
Loading