1import * as Effect from "../../Effect.ts"
2import * as Redacted from "../../Redacted.ts"
3import type { Identity } from "../EventLog.ts"
4
5const textEncoder = new TextEncoder()
6
7const Ed25519PublicKeyLength = 32
8
9const Ed25519Pkcs8SeedPrefix = Uint8Array.from([
10 0x30,
11 0x2e,
12 0x02,
13 0x01,
14 0x00,
15 0x30,
16 0x05,
17 0x06,
18 0x03,
19 0x2b,
20 0x65,
21 0x70,
22 0x04,
23 0x22,
24 0x04,
25 0x20
26])
27
28/**
29 * @internal
30 */
31export const EncryptionDerivationLabelV1 = "effect/eventlog/identity/v1/encryption"
32
33/**
34 * @internal
35 */
36export const SigningDerivationLabelV1 = "effect/eventlog/identity/v1/signing"
37
38/**
39 * @internal
40 */
41export interface IdentityRootSecretMaterial {
42 readonly encryptionKeyMaterial: Uint8Array<ArrayBuffer>
43 readonly encryptionKey: CryptoKey
44 readonly signingPublicKey: Uint8Array<ArrayBuffer>
45 readonly signingPrivateKey: Redacted.Redacted<Uint8Array<ArrayBuffer>>
46}
47
48const toArrayBuffer = (data: Uint8Array): ArrayBuffer => {
49 const copy = new Uint8Array(data.byteLength)
50 copy.set(data)
51 return copy.buffer
52}
53
54const decodeBase64Url = (value: string): Uint8Array<ArrayBuffer> => {
55 const normalized = value.replaceAll("-", "+").replaceAll("_", "/")
56 const remainder = normalized.length % 4
57 const padded = remainder === 0 ? normalized : `${normalized}${"=".repeat(4 - remainder)}`
58 const decoded = atob(padded)
59 const bytes = new Uint8Array(decoded.length)
60 for (let i = 0; i < decoded.length; i++) {
61 bytes[i] = decoded.charCodeAt(i)
62 }
63 return bytes
64}
65
66const makeEd25519Pkcs8FromSeed = (seed: Uint8Array): Uint8Array<ArrayBuffer> => {
67 const key = new Uint8Array(Ed25519Pkcs8SeedPrefix.byteLength + seed.byteLength)
68 key.set(Ed25519Pkcs8SeedPrefix, 0)
69 key.set(seed, Ed25519Pkcs8SeedPrefix.byteLength)
70 return key
71}
72
73const deriveSecretBytes = Effect.fnUntraced(function*(options: {
74 readonly crypto: Crypto
75 readonly rootSecret: Uint8Array
76 readonly label: string
77}) {
78 const labelBytes = textEncoder.encode(options.label)
79 const derivationInput = new Uint8Array(labelBytes.byteLength + 1 + options.rootSecret.byteLength)
80 derivationInput.set(labelBytes, 0)
81 derivationInput[labelBytes.byteLength] = 0
82 derivationInput.set(options.rootSecret, labelBytes.byteLength + 1)
83
84 const digest = yield* Effect.promise(() => options.crypto.subtle.digest("SHA-256", toArrayBuffer(derivationInput)))
85
86 return new Uint8Array(digest)
87})
88
89/**
90 * @internal
91 */
92export const deriveIdentityRootSecretMaterial = Effect.fnUntraced(function*(options: {
93 readonly crypto: Crypto
94 readonly rootSecret: Uint8Array
95}) {
96 const encryptionKeyMaterial = yield* deriveSecretBytes({
97 crypto: options.crypto,
98 rootSecret: options.rootSecret,
99 label: EncryptionDerivationLabelV1
100 })
101 const signingSeed = yield* deriveSecretBytes({
102 crypto: options.crypto,
103 rootSecret: options.rootSecret,
104 label: SigningDerivationLabelV1
105 })
106
107 const signingPrivateKeyBytes = makeEd25519Pkcs8FromSeed(signingSeed)
108
109 const encryptionKey = yield* Effect.promise(() =>
110 options.crypto.subtle.importKey("raw", toArrayBuffer(encryptionKeyMaterial), "AES-GCM", true, [
111 "encrypt",
112 "decrypt"
113 ])
114 )
115 const signingPrivateKey = yield* Effect.promise(() =>
116 options.crypto.subtle.importKey("pkcs8", toArrayBuffer(signingPrivateKeyBytes), "Ed25519", true, ["sign"])
117 )
118 const signingJwk = yield* Effect.promise(() => options.crypto.subtle.exportKey("jwk", signingPrivateKey))
119
120 if (typeof signingJwk.x !== "string") {
121 return yield* Effect.die(new Error("Unable to export deterministic Ed25519 public key"))
122 }
123
124 const signingPublicKey = decodeBase64Url(signingJwk.x)
125
126 if (signingPublicKey.byteLength !== Ed25519PublicKeyLength) {
127 return yield* Effect.die(
128 new Error(`Expected derived signing public key to be ${Ed25519PublicKeyLength} bytes`)
129 )
130 }
131
132 return {
133 encryptionKeyMaterial,
134 encryptionKey,
135 signingPublicKey,
136 signingPrivateKey: Redacted.make(signingPrivateKeyBytes)
137 } satisfies IdentityRootSecretMaterial
138})
139
140/**
141 * @internal
142 */
143export const makeGetIdentityRootSecretMaterial = (crypto: Crypto) => {
144 const cache = new WeakMap<Identity["Service"], IdentityRootSecretMaterial>()
145
146 return Effect.fnUntraced(function*(identity: Identity["Service"]) {
147 const cached = cache.get(identity)
148 if (cached !== undefined) {
149 return cached
150 }
151
152 const derived = yield* deriveIdentityRootSecretMaterial({
153 crypto,
154 rootSecret: Redacted.value(identity.privateKey)
155 })
156
157 yield* Effect.sync(() => {
158 cache.set(identity, derived)
159 })
160
161 return derived
162 })
163}