#INCLUDE "..\iguanacms.bi"
'ACME Client Implementation
DIM SHARED AS JSON_Member Properties(), Resources()
DIM SHARED AS STRING Nonce, Response, CertRequest
'Declare global objects
DIM SHARED _
Account AS ACME_Account, _
Order AS ACME_Order, _
Authorization AS ACME_Authorization, _
Identifier AS ACME_Identifier, _
Challenge AS ACME_Challenge
FUNCTION ACME_Init(FileContent AS STRING) AS LONG
DIM objProperties AS JSON_Object
objProperties.RawContent = FileContent
JSON_CountMembers(objProperties)
IF objProperties.MemberCount THEN
REDIM Properties(objProperties.MemberCount - 1)
JSON_ParseObject(objProperties, Properties())
ACME_Init = -1
END IF
END FUNCTION
SUB ACME_Terminate()
IF UBOUND(Properties) >= 0 THEN
ERASE Properties
END IF
END SUB
SUB ACME_SendRequest(Method AS STRING, Url AS STRING, Content AS STRING = "")
IF Url <> "" THEN
HTTP_Open(Method, Url)
HTTP_SetTimeOuts()
IF Content <> "" THEN
HTTP_SetRequestHeader("Content-Type", "application/jose+json")
END IF
HTTP_Send(Content)
END IF
END SUB
FUNCTION ACME_DumpResponse() AS STRING
ACME_DumpResponse = Response
END FUNCTION
SUB ACME_ParseResponse(Response() AS JSON_Member, ResponseContent AS STRING)
DIM objResponse AS JSON_Object
IF ResponseContent <> "" THEN
objResponse.RawContent = ResponseContent
JSON_CountMembers(objResponse)
END IF
IF objResponse.MemberCount THEN
REDIM Response(objResponse.MemberCount - 1)
JSON_ParseObject(objResponse, Response())
END IF
END SUB
FUNCTION ACME_GetProperty(PropertyName AS STRING) AS STRING
DIM Result AS STRING
Result = JSON_GetMemberValue(Properties(), PropertyName)
IF Result <> "" AND Result <> PropertyName THEN
ACME_GetProperty = Result
END IF
END FUNCTION
SUB ACME_UpdateProperty(PropertyName AS STRING, PropertyValue AS STRING)
JSON_SetMemberValue(Properties(), PropertyName, PropertyValue)
END SUB
FUNCTION ACME_DumpProperties() AS STRING
ACME_DumpProperties = JSON_GenerateObject(Properties())
END FUNCTION
'NOTE: This function expects an initialized private key
FUNCTION ACME_BuildWebKey() AS STRING
DIM objKey AS JSON_Object, PubKey AS RSA_PublicKey
PubKey = Crypto_ExportRawPublicKey()
JSON_FillObject(objKey, JWK_KEY_TYPE, JSON_GenerateString(KEY_TYPE))
JSON_FillObject(objKey, JWK_KEY_LEN, JSON_GenerateString(Base64Encode(PubKey.KeyLen, -1)))
JSON_FillObject(objKey, JWK_KEY_PUB_EXP, JSON_GenerateString(Base64Encode(PubKey.PubExp, -1)))
JSON_FillObject(objKey, JWK_KEY_ALG, JSON_GenerateString(KEY_ALG))
ACME_BuildWebKey = objKey.RawContent
END FUNCTION
'NOTE: This function expects an initialized private key
FUNCTION ACME_BuildKeyThumbprint() AS STRING
DIM objKey AS JSON_Object, PubKey AS RSA_PublicKey, Result AS STRING
PubKey = Crypto_ExportRawPublicKey()
JSON_FillObject(objKey, JWK_KEY_LEN, JSON_GenerateString(Base64Encode(PubKey.KeyLen, -1)))
JSON_FillObject(objKey, JWK_KEY_TYPE, JSON_GenerateString(KEY_TYPE))
JSON_FillObject(objKey, JWK_KEY_PUB_EXP, JSON_GenerateString(Base64Encode(PubKey.PubExp, -1)))
Result = objKey.RawContent
Result = Replace(Result, CHR(JSON_TOKEN_SPACE), "")
Result = Replace(Result, CHR(JSON_TOKEN_LF), "")
Result = SHA256(Result, -1)
ACME_BuildKeyThumbprint = Base64Encode(Result, -1)
END FUNCTION
'NOTE: This function expects an initialized private key
FUNCTION ACME_BuildCertRequest(RdnSubject AS STRING) AS STRING
DIM Result AS STRING
Crypto_InitializeCertRequest(RdnSubject)
Result = Crypto_GetCertRequest()
Crypto_TerminateCertRequest()
ACME_BuildCertRequest = Base64Encode(Result, -1)
END FUNCTION
FUNCTION ACME_BuildProtectedHeader(Url AS STRING, Kid AS STRING = "") AS STRING
DIM objHeader AS JSON_Object
JSON_FillObject(objHeader, JWS_URL, Replace(JSON_GenerateString(Url), CHR(JSON_TOKEN_ESCAPE), ""))
JSON_FillObject(objHeader, JWS_NONCE, JSON_GenerateString(Nonce))
'Are we receiving a key id?
IF Kid <> "" THEN
JSON_FillObject(objHeader, JWS_KID, JSON_GenerateString(Kid))
ELSE
'Add the JWK object
JSON_FillObject(objHeader, JWS_JWK, ACME_BuildWebKey())
END IF
JSON_FillObject(objHeader, JWK_KEY_ALG, JSON_GenerateString(KEY_ALG))
ACME_BuildProtectedHeader = Latin1ToUtf8(objHeader.RawContent)
END FUNCTION
FUNCTION ACME_BuildExternalBinding(Url AS STRING) AS STRING
DIM AS JSON_Object objHeader, objPayload, objEAB
DIM AS STRING Kid, HMacKey, Header, Payload, Signature
Kid = ACME_GetProperty("eab_kid")
HMacKey = ACME_GetProperty("eab_hmac")
IF Kid <> "" AND HMacKey <> "" THEN
JSON_FillObject(objHeader, JWS_URL, Replace(JSON_GenerateString(Url), CHR(JSON_TOKEN_ESCAPE), ""))
JSON_FillObject(objHeader, JWS_KID, JSON_GenerateString(Kid))
JSON_FillObject(objHeader, JWK_KEY_ALG, JSON_GenerateString(KEY_HMAC_ALG))
Header = Base64Encode(objHeader.RawContent, -1)
Payload = Base64Encode(ACME_BuildWebKey(), -1)
Signature = Base64Encode(HMAC("HMAC-SHA256", HMacKey, Header + "." + Payload), -1)
JSON_FillObject(objEAB, "signature", JSON_GenerateString(Signature))
JSON_FillObject(objEAB, "payload", JSON_GenerateString(Payload))
JSON_FillObject(objEAB, "protected", JSON_GenerateString(Header))
ACME_BuildExternalBinding = objEAB.RawContent
END IF
END FUNCTION
FUNCTION ACME_BuildIdentifier(Identifier AS ACME_Identifier) AS STRING
DIM objIdentifier AS JSON_Object
JSON_FillObject(objIdentifier, "type", JSON_GenerateString(Identifier.Type))
JSON_FillObject(objIdentifier, "value", JSON_GenerateString(Identifier.Value))
ACME_BuildIdentifier = objIdentifier.RawContent
END FUNCTION
FUNCTION ACME_BuildPayload(Resource AS ACME_Resource) AS STRING
DIM objPayload AS JSON_Object
SELECT CASE Resource
CASE ACME_NEW_ACCOUNT
DIM ArrayContacts AS JSON_Array, ContactsCount AS LONG, ExternalBinding AS STRING
IF ACME_RequiresExternalBinding() THEN
'Add external account binding
ExternalBinding = ACME_BuildExternalBinding(ACME_GetResourceUrl("newAccount"))
IF ExternalBinding <> "" THEN
JSON_FillObject(objPayload, "externalAccountBinding", ExternalBinding)
END IF
END IF
'Agree terms of service
JSON_FillObject(objPayload, "termsOfServiceAgreed", "true")
'Add email address
ContactsCount = UBOUND(Account.Contacts)
IF ContactsCount >= 0 THEN
FOR i AS LONG = 0 TO ContactsCount
JSON_FillArray(ArrayContacts, JSON_GenerateString(Account.Contacts(i)))
NEXT
END IF
JSON_FillObject(objPayload, "contact", ArrayContacts.RawContent)
CASE ACME_NEW_ORDER
DIM ArrayIdentifiers AS JSON_Array, IdentifiersCount AS LONG
'Get identifiers count from the global order object
IdentifiersCount = UBOUND(Order.Identifiers)
IF IdentifiersCount >= 0 THEN
FOR i AS LONG = 0 TO IdentifiersCount
JSON_FillArray(ArrayIdentifiers, ACME_BuildIdentifier(Order.Identifiers(i)))
NEXT
END IF
JSON_FillObject(objPayload, "identifiers", ArrayIdentifiers.RawContent)
CASE ACME_ORDER_FINALIZE
JSON_FillObject(objPayload, "csr", JSON_GenerateString(CertRequest))
END SELECT
ACME_BuildPayload = objPayload.RawContent
END FUNCTION
'NOTE: This function expects an initialized private key
FUNCTION ACME_BuildRequest(Resource AS ACME_Resource) AS STRING
DIM oJWS AS JSON_Object
DIM AS STRING Header, Payload, Signature
IF Account.Url = "" THEN
Account.Url = ACME_GetProperty("account_url")
END IF
SELECT CASE Resource
CASE ACME_NEW_ACCOUNT
Header = ACME_BuildProtectedHeader(ACME_GetResourceUrl("newAccount"))
Payload = ACME_BuildPayload(ACME_NEW_ACCOUNT)
CASE ACME_EXISTING_ACCOUNT
Header = ACME_BuildProtectedHeader(Account.Url, Account.Url)
CASE ACME_NEW_ORDER
Header = ACME_BuildProtectedHeader(ACME_GetResourceUrl("newOrder"), Account.Url)
Payload = ACME_BuildPayload(ACME_NEW_ORDER)
CASE ACME_EXISTING_ORDER
IF Order.Url <> "" THEN
Header = ACME_BuildProtectedHeader(Order.Url, Account.Url)
END IF
CASE ACME_EXISTING_AUTHORIZATION
IF Authorization.Url <> "" THEN
Header = ACME_BuildProtectedHeader(Authorization.Url, Account.Url)
END IF
CASE ACME_EXISTING_CHALLENGE
IF Challenge.Url <> "" THEN
Header = ACME_BuildProtectedHeader(Challenge.Url, Account.Url)
Payload = CHR(JSON_TOKEN_OBJECT_OPEN) + CHR(JSON_TOKEN_OBJECT_CLOSE)
END IF
CASE ACME_ORDER_FINALIZE
IF Order.Finalize <> "" THEN
Header = ACME_BuildProtectedHeader(Order.Finalize, Account.Url)
Payload = ACME_BuildPayload(ACME_ORDER_FINALIZE)
END IF
CASE ACME_CERT
IF Order.Certificate <> "" THEN
Header = ACME_BuildProtectedHeader(Order.Certificate, Account.Url)
END IF
END SELECT
IF Header <> "" THEN
Header = Base64Encode(Header, -1)
'If payload comes empty, then it is a POST-as-GET request
IF Payload <> "" THEN
Payload = Base64Encode(Payload, -1)
END IF
'Build the message signature
Signature = Base64Encode(Crypto_SignMessage(Header + "." + Payload), -1)
'Build the JWS object
JSON_FillObject(oJWS, "signature", JSON_GenerateString(Signature))
JSON_FillObject(oJWS, "payload", JSON_GenerateString(Payload))
JSON_FillObject(oJWS, "protected", JSON_GenerateString(Header))
END IF
ACME_BuildRequest = oJWS.RawContent
END FUNCTION
SUB ACME_GetResources()
DIM AS STRING DirectoryUrl = ACME_GetProperty("directory_url")
IF DirectoryUrl <> "" THEN
ACME_SendRequest("GET", DirectoryUrl)
Response = HTTP_GetResponse()
IF Response <> "" THEN
ACME_ParseResponse(Resources(), Response)
END IF
END IF
END SUB
FUNCTION ACME_GetResourceUrl(ResourceName AS STRING) AS STRING
DIM Result AS STRING = JSON_GetMemberValue(Resources(), ResourceName)
IF Result <> "" THEN
ACME_GetResourceUrl = Result
END IF
END FUNCTION
SUB ACME_UpdateNonce()
Nonce = HTTP_GetResponseHeader("Replay-Nonce")
END SUB
SUB ACME_GenerateNonce()
DIM NewNonceUrl AS STRING = ACME_GetResourceUrl("newNonce")
IF NewNonceUrl <> "" THEN
ACME_SendRequest("HEAD", NewNonceUrl)
ACME_UpdateNonce()
END IF
END SUB
FUNCTION ACME_RequiresExternalBinding() AS LONG
DIM MetaData() AS JSON_Member
ACME_ParseResponse(MetaData(), ACME_GetResourceUrl("meta"))
IF UBOUND(MetaData) >= 0 THEN
IF JSON_GetMemberValue(MetaData(), "externalAccountRequired") = "true" THEN
ACME_RequiresExternalBinding = -1
END IF
END IF
END FUNCTION
'Creates a new account, and on success returns the account url
FUNCTION ACME_CreateAccount(Contacts() AS STRING) AS STRING
DIM AS STRING NewAccountUrl, RequestContent
DIM ContactsCount AS LONG = UBOUND(Contacts)
IF ContactsCount >= 0 THEN
REDIM Account.Contacts(ContactsCount)
FOR i AS LONG = 0 TO ContactsCount
Account.Contacts(i) = Contacts(i)
NEXT
END IF
NewAccountUrl = ACME_GetResourceUrl("newAccount")
RequestContent = ACME_BuildRequest(ACME_NEW_ACCOUNT)
IF NewAccountUrl <> "" AND RequestContent <> "" THEN
ACME_SendRequest("POST", NewAccountUrl, RequestContent)
ACME_UpdateNonce()
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode = "200" THEN
ACME_CreateAccount = HTTP_GetResponseHeader("Location")
END IF
END FUNCTION
FUNCTION ACME_GetAccountInfo(AccountUrl AS STRING) AS ACME_Account
DIM CurrentAccount AS ACME_Account, AccountData() AS JSON_Member
DIM AS STRING RequestContent
IF AccountUrl <> "" THEN
Account.Url = AccountUrl
END IF
RequestContent = ACME_BuildRequest(ACME_EXISTING_ACCOUNT)
IF RequestContent <> "" THEN
ACME_SendRequest("POST", AccountUrl, RequestContent)
ACME_UpdateNonce()
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode() = "200" THEN
ACME_ParseResponse(AccountData(), Response)
END IF
IF UBOUND(AccountData) >= 0 THEN
WITH CurrentAccount
.Status = JSON_GetMemberValue(AccountData(), "status")
END WITH
ACME_GetAccountInfo = CurrentAccount
END IF
END FUNCTION
'Creates a new order, and on success returns the order url
FUNCTION ACME_InitializeOrder(Identifiers() AS STRING) AS STRING
DIM AS STRING NewOrderUrl, RequestContent
DIM IdentifiersCount AS LONG = UBOUND(Identifiers)
IF IdentifiersCount >= 0 THEN
REDIM Order.Identifiers(IdentifiersCount)
FOR i AS LONG = 0 TO IdentifiersCount
WITH Order.Identifiers(i)
.Type = "dns"
.Value = Identifiers(i)
END WITH
NEXT
END IF
NewOrderUrl = ACME_GetResourceUrl("newOrder")
RequestContent = ACME_BuildRequest(ACME_NEW_ORDER)
IF NewOrderUrl <> "" AND RequestContent <> "" THEN
ACME_SendRequest("POST", NewOrderUrl, RequestContent)
ACME_UpdateNonce()
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode = "201" THEN
ACME_InitializeOrder = HTTP_GetResponseHeader("Location")
END IF
END FUNCTION
FUNCTION ACME_GetIdentifierInfo(IdentifierContent AS STRING) AS ACME_Identifier
DIM CurrentIdentifier AS ACME_Identifier, IdentifierData() AS JSON_Member
ACME_ParseResponse(IdentifierData(), IdentifierContent)
IF UBOUND(IdentifierData) >= 0 THEN
WITH CurrentIdentifier
.Type = JSON_GetMemberValue(IdentifierData(), "type")
.Value = JSON_GetMemberValue(IdentifierData(), "value")
END WITH
ACME_GetIdentifierInfo = CurrentIdentifier
END IF
END FUNCTION
FUNCTION ACME_GetOrderInfo(OrderUrl AS STRING) AS ACME_Order
DIM CurrentOrder AS ACME_Order, OrderData() AS JSON_Member
DIM AS STRING RequestContent
IF OrderUrl <> "" THEN
Order.Url = OrderUrl
END IF
RequestContent = ACME_BuildRequest(ACME_EXISTING_ORDER)
IF RequestContent <> "" THEN
ACME_SendRequest("POST", OrderUrl, RequestContent)
ACME_UpdateNonce()
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode() = "200" THEN
ACME_ParseResponse(OrderData(), Response)
END IF
IF UBOUND(OrderData) >= 0 THEN
'Parse the identifiers
DIM ArrayIdentifiers AS JSON_Array, Identifiers() AS JSON_Value, IdentifiersCount AS LONG
ArrayIdentifiers.RawContent = JSON_GetMemberValue(OrderData(), "identifiers")
JSON_CountValues(ArrayIdentifiers)
IF ArrayIdentifiers.ValueCount THEN
REDIM Identifiers(ArrayIdentifiers.ValueCount - 1)
JSON_ParseArray(ArrayIdentifiers, Identifiers())
END IF
'Parse the authorizations
DIM ArrayAuths AS JSON_Array, Auths() AS JSON_Value, AuthsCount AS LONG
ArrayAuths.RawContent = JSON_GetMemberValue(OrderData(), "authorizations")
JSON_CountValues(ArrayAuths)
IF ArrayAuths.ValueCount THEN
REDIM Auths(ArrayAuths.ValueCount - 1)
JSON_ParseArray(ArrayAuths, Auths())
END IF
WITH CurrentOrder
.Status = JSON_GetMemberValue(OrderData(), "status")
.Expires = JSON_GetMemberValue(OrderData(), "expires")
.Finalize = JSON_GetMemberValue(OrderData(), "finalize")
.Certificate = JSON_GetMemberValue(OrderData(), "certificate")
IdentifiersCount = UBOUND(Identifiers)
IF IdentifiersCount >= 0 THEN
REDIM .Identifiers(IdentifiersCount)
FOR i AS LONG = 0 TO IdentifiersCount
.Identifiers(i) = ACME_GetIdentifierInfo(Identifiers(i).RawContent)
NEXT
END IF
AuthsCount = UBOUND(Auths)
IF AuthsCount >= 0 THEN
REDIM .Authorizations(AuthsCount)
FOR i AS LONG = 0 TO AuthsCount
.Authorizations(i) = JSON_ParseString(Auths(i).RawContent)
NEXT
END IF
END WITH
ACME_GetOrderInfo = CurrentOrder
END IF
END FUNCTION
FUNCTION ACME_GetChallengeInfo(ChallengeContent AS STRING) AS ACME_Challenge
DIM CurrentChallenge AS ACME_Challenge, ChallengeData() AS JSON_Member
ACME_ParseResponse(ChallengeData(), ChallengeContent)
IF UBOUND(ChallengeData) >= 0 THEN
WITH CurrentChallenge
.Type = JSON_GetMemberValue(ChallengeData(), "type")
.Status = JSON_GetMemberValue(ChallengeData(), "status")
.Url = JSON_GetMemberValue(ChallengeData(), "url")
.Token = JSON_GetMemberValue(ChallengeData(), "token")
END WITH
ACME_GetChallengeInfo = CurrentChallenge
END IF
END FUNCTION
FUNCTION ACME_GetAuthorizationInfo(AuthUrl AS STRING) AS ACME_Authorization
DIM CurrentAuth AS ACME_Authorization, AuthData() AS JSON_Member
DIM AS STRING RequestContent
IF AuthUrl <> "" THEN
Authorization.Url = AuthUrl
RequestContent = ACME_BuildRequest(ACME_EXISTING_AUTHORIZATION)
END IF
IF RequestContent <> "" THEN
ACME_SendRequest("POST", AuthUrl, RequestContent)
ACME_UpdateNonce()
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode = "200" THEN
ACME_ParseResponse(AuthData(), Response)
END IF
IF UBOUND(AuthData) >= 0 THEN
DIM ArrayChallenges AS JSON_Array, Challenges() AS JSON_Value, ChallengesCount AS LONG
ArrayChallenges.RawContent = JSON_GetMemberValue(AuthData(), "challenges")
JSON_CountValues(ArrayChallenges)
IF ArrayChallenges.ValueCount THEN
REDIM Challenges(ArrayChallenges.ValueCount - 1)
JSON_ParseArray(ArrayChallenges, Challenges())
END IF
WITH CurrentAuth
.Status = JSON_GetMemberValue(AuthData(), "status")
.Expires = JSON_GetMemberValue(AuthData(), "expires")
.Identifier = ACME_GetIdentifierInfo(JSON_GetMemberValue(AuthData(), "identifier"))
ChallengesCount = UBOUND(Challenges)
IF ChallengesCount >= 0 THEN
REDIM .Challenges(ChallengesCount)
FOR i AS LONG = 0 TO ChallengesCount
.Challenges(i) = ACME_GetChallengeInfo(Challenges(i).RawContent)
NEXT
END IF
END WITH
ACME_GetAuthorizationInfo = CurrentAuth
END IF
END FUNCTION
FUNCTION ACME_GetKeyAuthorization(Token AS STRING) AS STRING
ACME_GetKeyAuthorization = Token + "." + ACME_BuildKeyThumbprint()
END FUNCTION
FUNCTION ACME_ValidateChallenge(ChallengeUrl AS STRING) AS LONG
DIM AS STRING RequestContent
IF ChallengeUrl <> "" THEN
Challenge.Url = ChallengeUrl
RequestContent = ACME_BuildRequest(ACME_EXISTING_CHALLENGE)
END IF
IF RequestContent <> "" THEN
ACME_SendRequest("POST", ChallengeUrl, RequestContent)
ACME_UpdateNonce()
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode = "200" THEN
ACME_ValidateChallenge = -1
END IF
END FUNCTION
FUNCTION ACME_FinalizeOrder(FinalizeUrl AS STRING, CsrContent AS STRING) AS LONG
DIM OrderData() AS JSON_Member
DIM AS STRING RequestContent
IF FinalizeUrl <> "" THEN
CertRequest = CsrContent
Order.Finalize = FinalizeUrl
RequestContent = ACME_BuildRequest(ACME_ORDER_FINALIZE)
END IF
IF RequestContent <> "" THEN
ACME_SendRequest("POST", FinalizeUrl, RequestContent)
ACME_UpdateNonce()
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode = "200" THEN
ACME_FinalizeOrder = -1
END IF
END FUNCTION
FUNCTION ACME_DownloadCertificate(CertUrl AS STRING) AS STRING
DIM AS STRING RequestContent
Order.Certificate = CertUrl
RequestContent = ACME_BuildRequest(ACME_CERT)
IF RequestContent <> "" THEN
ACME_SendRequest("POST", CertUrl, RequestContent)
Response = HTTP_GetResponse()
END IF
IF HTTP_GetResponseCode = "200" THEN
ACME_DownloadCertificate = Response
END IF
END FUNCTION