an atproto pds written in F# (.NET 9) 馃
pds
fsharp
giraffe
dotnet
atproto
1namespace PDSharp.Core
2
3open System
4open System.Text
5open Org.BouncyCastle.Crypto.Digests
6open Org.BouncyCastle.Crypto.Macs
7open Org.BouncyCastle.Crypto.Parameters
8open Org.BouncyCastle.Security
9
10/// Authentication module for sessions and accounts
11/// TODO: Migrate account storage from in-memory to SQLite/Postgres for production
12module Auth =
13 /// Hash a password with a random salt using SHA-256
14 ///
15 /// Returns: base64(salt)$base64(hash)
16 let hashPassword (password : string) : string =
17 let salt = Array.zeroCreate<byte> 16
18 SecureRandom().NextBytes(salt)
19
20 let passwordBytes = Encoding.UTF8.GetBytes(password)
21 let toHash = Array.append salt passwordBytes
22
23 let digest = Sha256Digest()
24 digest.BlockUpdate(toHash, 0, toHash.Length)
25 let hash = Array.zeroCreate<byte> (digest.GetDigestSize())
26 digest.DoFinal(hash, 0) |> ignore
27
28 $"{Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"
29
30 /// Verify a password against a stored hash
31 let verifyPassword (password : string) (storedHash : string) : bool =
32 let parts = storedHash.Split('$')
33
34 if parts.Length <> 2 then
35 false
36 else
37 try
38 let salt = Convert.FromBase64String(parts.[0])
39 let expectedHash = Convert.FromBase64String(parts.[1])
40
41 let passwordBytes = Encoding.UTF8.GetBytes(password)
42 let toHash = Array.append salt passwordBytes
43
44 let digest = Sha256Digest()
45 digest.BlockUpdate(toHash, 0, toHash.Length)
46 let actualHash = Array.zeroCreate<byte> (digest.GetDigestSize())
47 digest.DoFinal(actualHash, 0) |> ignore
48
49 actualHash = expectedHash
50 with _ ->
51 false
52
53 let private base64UrlEncode (bytes : byte[]) : string =
54 Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=')
55
56 let private base64UrlDecode (str : string) : byte[] =
57 let padded =
58 match str.Length % 4 with
59 | 2 -> str + "=="
60 | 3 -> str + "="
61 | _ -> str
62
63 Convert.FromBase64String(padded.Replace('-', '+').Replace('_', '/'))
64
65 let private hmacSha256 (secret : byte[]) (data : byte[]) : byte[] =
66 let hmac = HMac(Sha256Digest())
67 hmac.Init(KeyParameter(secret))
68 hmac.BlockUpdate(data, 0, data.Length)
69 let result = Array.zeroCreate<byte> (hmac.GetMacSize())
70 hmac.DoFinal(result, 0) |> ignore
71 result
72
73 /// Token type for domain separation per AT Protocol spec
74 type TokenType =
75 | Access // typ: at+jwt
76 | Refresh // typ: refresh+jwt
77
78 /// Create a JWT token
79 let createToken (secret : string) (tokenType : TokenType) (did : string) (expiresIn : TimeSpan) : string =
80 let now = DateTimeOffset.UtcNow
81 let exp = now.Add(expiresIn)
82
83 let typ =
84 match tokenType with
85 | Access -> "at+jwt"
86 | Refresh -> "refresh+jwt"
87
88 let jti = Guid.NewGuid().ToString("N")
89
90 let header = $"""{{ "alg": "HS256", "typ": "{typ}" }}"""
91 let headerB64 = base64UrlEncode (Encoding.UTF8.GetBytes(header))
92
93 let payload =
94 $"""{{ "sub": "{did}", "iat": {now.ToUnixTimeSeconds()}, "exp": {exp.ToUnixTimeSeconds()}, "jti": "{jti}" }}"""
95
96 let payloadB64 = base64UrlEncode (Encoding.UTF8.GetBytes(payload))
97
98 let signingInput = $"{headerB64}.{payloadB64}"
99 let secretBytes = Encoding.UTF8.GetBytes(secret)
100 let signature = hmacSha256 secretBytes (Encoding.UTF8.GetBytes(signingInput))
101 let signatureB64 = base64UrlEncode signature
102
103 $"{headerB64}.{payloadB64}.{signatureB64}"
104
105 /// Create an access token (short-lived)
106 let createAccessToken (secret : string) (did : string) : string =
107 createToken secret Access did (TimeSpan.FromMinutes(15.0))
108
109 /// Create a refresh token (longer-lived)
110 let createRefreshToken (secret : string) (did : string) : string =
111 createToken secret Refresh did (TimeSpan.FromDays(7.0))
112
113 /// Validation result
114 type TokenValidation =
115 | Valid of did : string * tokenType : TokenType * exp : int64
116 | Invalid of reason : string
117
118 /// Validate a JWT token and extract claims
119 let validateToken (secret : string) (token : string) : TokenValidation =
120 let parts = token.Split('.')
121
122 if parts.Length <> 3 then
123 Invalid "Invalid token format"
124 else
125 try
126 let headerB64, payloadB64, signatureB64 = parts.[0], parts.[1], parts.[2]
127
128 let signingInput = $"{headerB64}.{payloadB64}"
129 let secretBytes = Encoding.UTF8.GetBytes(secret)
130 let expectedSig = hmacSha256 secretBytes (Encoding.UTF8.GetBytes(signingInput))
131 let actualSig = base64UrlDecode signatureB64
132
133 if expectedSig <> actualSig then
134 Invalid "Invalid signature"
135 else
136 let payloadJson = Encoding.UTF8.GetString(base64UrlDecode payloadB64)
137 let headerJson = Encoding.UTF8.GetString(base64UrlDecode headerB64)
138
139 let typMatch =
140 System.Text.RegularExpressions.Regex.Match(headerJson, "\"typ\"\\s*:\\s*\"([^\"]+)\"")
141
142 let tokenType =
143 if typMatch.Success then
144 match typMatch.Groups.[1].Value with
145 | "at+jwt" -> Access
146 | "refresh+jwt" -> Refresh
147 | _ -> Access
148 else
149 Access
150
151 let subMatch =
152 System.Text.RegularExpressions.Regex.Match(payloadJson, "\"sub\"\\s*:\\s*\"([^\"]+)\"")
153
154 let expMatch =
155 System.Text.RegularExpressions.Regex.Match(payloadJson, "\"exp\"\\s*:\\s*([0-9]+)")
156
157 if not subMatch.Success || not expMatch.Success then
158 Invalid "Missing claims"
159 else
160 let did = subMatch.Groups.[1].Value
161 let exp = Int64.Parse(expMatch.Groups.[1].Value)
162 let now = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
163
164 if now > exp then
165 Invalid "Token expired"
166 else
167 Valid(did, tokenType, exp)
168 with ex ->
169 Invalid $"Parse error: {ex.Message}"
170
171 /// Account record
172 type Account = {
173 Did : string
174 Handle : string
175 PasswordHash : string
176 Email : string option
177 CreatedAt : DateTimeOffset
178 }
179
180 /// Interface for account persistence
181 type IAccountStore =
182 abstract member CreateAccount : Account -> Async<Result<unit, string>>
183 abstract member GetAccountByHandle : string -> Async<Account option>
184 abstract member GetAccountByDid : string -> Async<Account option>
185
186 /// Create a new account
187 let createAccount
188 (store : IAccountStore)
189 (handle : string)
190 (password : string)
191 (email : string option)
192 : Async<Result<Account, string>> =
193 async {
194 let! existingHandle = store.GetAccountByHandle handle
195
196 match existingHandle with
197 | Some _ -> return Error "Handle already taken"
198 | None ->
199 let did = $"did:web:{handle}"
200 let! existingDid = store.GetAccountByDid did
201
202 match existingDid with
203 | Some _ -> return Error "Account already exists"
204 | None ->
205 let account = {
206 Did = did
207 Handle = handle
208 PasswordHash = hashPassword password
209 Email = email
210 CreatedAt = DateTimeOffset.UtcNow
211 }
212
213 let! result = store.CreateAccount account
214
215 match result with
216 | Ok() -> return Ok account
217 | Error e -> return Error e
218 }