Skip to content
Source
Sign inSign up

dpop.ts

195 lines7.2 KB

1import * as Digest from "effect/crypto/Digest";
2import * as SecureRandom from "effect/crypto/SecureRandom";
3import * as Effect from "effect/Effect";
4import * as Base64Url from "effect/encoding/Base64Url";
5import * as Hex from "effect/encoding/Hex";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import * as ExpoCrypto from "expo-crypto";
9import * as SecureStore from "expo-secure-store";
10import { DpopProof } from "@t3tools/client-runtime/relay";
11import { DpopPublicJwk } from "@t3tools/shared/dpopCommon";
12import * as Layer from "effect/Layer";
13​
14export class CloudDpopError extends Schema.TaggedError<CloudDpopError>()("CloudDpopError", {
15 message: Schema.String,
16 cause: Schema.optional(Schema.Defect()),
17}) {}
18​
19function cloudDpopError(message: string) {
20 return (cause: unknown) => new CloudDpopError({ message, cause });
22​
23const DpopPrivateJwkSchema = Schema.Struct({
24 ...DpopPublicJwk.fields,
25 d: Schema.String,
26});
27​
28const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema);
29const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson);
30const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson);
31​
32function toExpoDigestAlgorithm(algorithm: Digest.Algorithm): ExpoCrypto.CryptoDigestAlgorithm {
33 switch (algorithm.name) {
34 case "MD5":
35 return ExpoCrypto.CryptoDigestAlgorithm.MD5;
36 case "SHA-1":
37 return ExpoCrypto.CryptoDigestAlgorithm.SHA1;
38 case "SHA-256":
39 return ExpoCrypto.CryptoDigestAlgorithm.SHA256;
40 case "SHA-384":
41 return ExpoCrypto.CryptoDigestAlgorithm.SHA384;
42 case "SHA-512":
43 return ExpoCrypto.CryptoDigestAlgorithm.SHA512;
44 }
46​
47// Hermes has no SubtleCrypto, so Effect's WebCrypto layers cannot back these services.
48const digest = (algorithm: Digest.Algorithm, data: Uint8Array) =>
49 Effect.promise(async () => {
50 const input = new Uint8Array(data.length);
51 input.set(data);
52 return new Uint8Array(await ExpoCrypto.digest(toExpoDigestAlgorithm(algorithm), input));
53 });
54​
55export const layer = Layer.mergeAll(
56 Layer.succeed(SecureRandom.SecureRandom, SecureRandom.make(ExpoCrypto.getRandomBytes)),
57 Layer.succeed(
58 Digest.Digest,
59 Digest.Digest.of({
60 digest,
61 digestHex: Effect.fn("Digest.Digest.digestHex")((algorithm, data) =>
62 Effect.map(digest(algorithm, data), Hex.encode),
63 ),
64 }),
65 ),
66);
67​
68type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type;
69​
70export interface DpopProofKeyPair {
71 readonly privateJwk: DpopPrivateJwk;
72 readonly publicJwk: DpopPublicJwk;
74​
75const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key";
76​
77interface NativeDpopKeys {
78 /** A 32-byte private scalar followed by its 65-byte uncompressed public point. */
79 generateDpopKey(): Promise<Uint8Array>;
80 dpopKeyMatches(privateKey: Uint8Array, publicKey: Uint8Array): Promise<boolean>;
81 /** ES256 over the signing input, as the raw 64-byte `r‖s` pair. */
82 signDpop(privateKey: Uint8Array, signingInput: Uint8Array): Promise<Uint8Array>;
84​
85// Platform crypto: in Hermes, @noble/curves blocks JS for about a second on its first P-256 key.
86// Imported lazily: loading `expo` at module scope breaks Node tests that import this file.
87const nativeDpopKeys = async () =>
88 (await import("expo")).requireNativeModule<NativeDpopKeys>("T3NativeControls");
89​
90function base64UrlToBytes(value: string): Uint8Array {
91 return Result.getOrThrow(Base64Url.decode(value));
93​
94function coordinateBytes(value: string): Uint8Array {
95 const bytes = base64UrlToBytes(value);
96 if (bytes.length !== 32 || Base64Url.encode(bytes) !== value) {
97 throw new Error("DPoP key coordinate is not 32 canonical bytes.");
98 }
99 return bytes;
101​
102function uncompressedPublicKeyFromJwk(jwk: DpopPublicJwk): Uint8Array {
103 const publicKey = new Uint8Array(65);
104 publicKey[0] = 0x04;
105 publicKey.set(coordinateBytes(jwk.x), 1);
106 publicKey.set(coordinateBytes(jwk.y), 33);
107 return publicKey;
109​
110function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {
111 if (publicKey.length !== 65 || publicKey[0] !== 0x04) {
112 throw new Error("Generated DPoP public key is not an uncompressed P-256 point.");
113 }
114 return {
115 kty: "EC",
116 crv: "P-256",
117 x: Base64Url.encode(publicKey.slice(1, 33)),
118 y: Base64Url.encode(publicKey.slice(33, 65)),
119 };
121​
122function privateJwkFromPrivateKey(
123 privateKey: Uint8Array,
124 publicJwk: DpopPublicJwk,
125): DpopPrivateJwk {
126 return { ...publicJwk, d: Base64Url.encode(privateKey) };
128​
129export const generateDpopProofKeyPair = Effect.fn("dpop.generateDpopProofKeyPair")(
130 function* (): Effect.fn.Return<DpopProofKeyPair, CloudDpopError> {
131 const key = yield* Effect.tryPromise({
132 try: async () => (await nativeDpopKeys()).generateDpopKey(),
133 catch: cloudDpopError("Could not generate the DPoP key pair."),
134 });
135 const publicJwk = yield* Effect.try({
136 try: () => publicJwkFromUncompressedPublicKey(key.slice(32)),
137 catch: cloudDpopError("Generated DPoP public key is invalid."),
138 });
139 return { privateJwk: privateJwkFromPrivateKey(key.slice(0, 32), publicJwk), publicJwk };
140 },
141);
142​
143export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProofKeyPair")(
144 function* (): Effect.fn.Return<DpopProofKeyPair, CloudDpopError> {
145 const stored = yield* Effect.tryPromise({
146 try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY),
147 catch: cloudDpopError("Could not read the DPoP proof key."),
148 });
149 if (stored) {
150 const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe(
151 Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")),
152 );
153 const { d, ...publicJwk } = storedPrivateJwk;
154 const matches = yield* Effect.tryPromise({
155 try: async () =>
156 (await nativeDpopKeys()).dpopKeyMatches(
157 base64UrlToBytes(d),
158 uncompressedPublicKeyFromJwk(publicJwk),
159 ),
160 catch: cloudDpopError("Stored DPoP proof key is invalid."),
161 });
162 if (!matches) {
163 return yield* new CloudDpopError({ message: "Stored DPoP proof key is invalid." });
164 }
165 return { privateJwk: storedPrivateJwk, publicJwk };
166 }
167 const generated = yield* generateDpopProofKeyPair();
168 const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(
169 Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")),
170 );
171 yield* Effect.tryPromise({
172 try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk),
173 catch: cloudDpopError("Could not store the DPoP proof key."),
174 });
175 return generated;
176 },
177);
178​
179/** The installation's DPoP key, signing with the platform's P-256. */
180export const loadOrCreateProofKey = Effect.fn("dpop.loadOrCreateProofKey")(function* () {
181 const keyPair = yield* loadOrCreateDpopProofKeyPair();
182 const privateKey = yield* Effect.try({
183 try: () => base64UrlToBytes(keyPair.privateJwk.d),
184 catch: cloudDpopError("Could not import DPoP private key."),
185 });
186 return {
187 publicJwk: keyPair.publicJwk,
188 sign: (signingInput) =>
189 Effect.tryPromise({
190 try: async () => (await nativeDpopKeys()).signDpop(privateKey, signingInput),
191 catch: (cause) =>
192 new DpopProof.DpopProofError({ message: "Could not sign DPoP proof.", cause }),
193 }),
194 } satisfies DpopProof.DpopProofKey;
195});