an atproto pds written in F# (.NET 9) 馃
pds fsharp giraffe dotnet atproto
at main 7.3 kB view raw
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 }