Skip to content

Commit

Permalink
Implement JWS.sign & JWS.verify (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
amika-sq committed Feb 9, 2024
1 parent 607dd60 commit 24a8cf3
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 3 deletions.
155 changes: 153 additions & 2 deletions Sources/Web5/Crypto/JOSE/JWS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ public struct JWS {

// Supported JWS algorithms
public enum Algorithm: String, Codable {
case eddsa
case es256k
case eddsa = "EdDSA"
case es256k = "ES256K"
}

/// JWS JOSE Header
Expand Down Expand Up @@ -106,8 +106,159 @@ public struct JWS {
case critical = "crit"
}
}

/// Signs the provided payload with a key associated with the provided `BearerDID`.
/// - Parameters:
/// - did: `BearerDID` to use for signing
/// - payload: Data to be signed
/// - detached: If true, the payload will not be included in the resulting compactJWS
/// - verificationID: Optional `VerificationMethod` ID to use for signing. If not provided, the first
/// assertionMethod in the `BearerDID`'s document will be used.
/// - Returns: compactJWS representation of the signed payload
public static func sign<D>(
did: BearerDID,
payload: D,
detached: Bool,
verificationMethodID: String? = nil
) throws -> String
where D: DataProtocol {
let signer = try did.getSigner(verificationMethodID: verificationMethodID)

guard let publicKey = signer.verificationMethod.publicKeyJwk else {
throw Error.signError("Public key not found")
}

guard let algorithm = publicKey.algorithm else {
throw Error.signError("Public key algorithm not found")
}

let header = Header(
algorithm: algorithm.jwsAlgorithm,
keyID: signer.verificationMethod.id
)

let base64UrlEncodedHeader = try JSONEncoder().encode(header).base64UrlEncodedString()
let base64UrlEncodedPayload = payload.base64UrlEncodedString()
let toSign = "\(base64UrlEncodedHeader).\(base64UrlEncodedPayload)"
let base64UrlEncodedSignature = try signer.sign(payload: Data(toSign.utf8)).base64UrlEncodedString()

let compactJWS: String
if detached {
compactJWS = "\(base64UrlEncodedHeader)..\(base64UrlEncodedSignature)"
} else {
compactJWS = "\(base64UrlEncodedHeader).\(base64UrlEncodedPayload).\(base64UrlEncodedSignature)"
}

return compactJWS
}

/// Verifies the integrity of a compactJWS representation of a signed payload.
/// - Parameters:
/// - compactJWS: compactJWS representation to verify
/// - detachedPayload: Optional detached payload to verify. If not provided, the payload will be assumed to be
/// attached within the compactJWS.
/// - expectedSigningDIDURI: Optional DID URI of the expected signer of the compactJWS. If not provided,
/// the DID URI will be extracted from the compactJWS, and will be assumed to be the correct signer.
/// - Returns: Boolean indicating whether the provided compactJWS is valid
public static func verify(
compactJWS: String?,
detachedPayload: (any DataProtocol)? = nil,
expectedSigningDIDURI: String? = nil
) async throws -> Bool {
guard let compactJWS else {
throw Error.verifyError("compactJWS not provided")
}

let parts = compactJWS.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3 else {
throw Error.verifyError("Malformed JWS - Expected 3 parts, got \(parts.count)")
}

let base64UrlEncodedPayload: String
if let detachedPayload {
guard parts[1].count == 0 else {
// Caller provided a detached payload to verify, but the compactJWS has a payload in it.
// Throw an error, as this is likely a mistake by the caller.
throw Error.verifyError("Expected detached payload")
}
base64UrlEncodedPayload = detachedPayload.base64UrlEncodedString()
} else {
base64UrlEncodedPayload = String(parts[1])
}

let base64UrlEncodedHeader = String(parts[0])
let header = try JSONDecoder().decode(
JWS.Header.self,
from: try base64UrlEncodedHeader.decodeBase64Url()
)

guard let verificationMethodID = header.keyID else {
throw Error.verifyError("Malformed JWS Header - `kid` is required")
}

let verificationMethodIDParts = verificationMethodID.split(separator: "#")
guard verificationMethodIDParts.count == 2 else {
throw Error.verifyError("Malformed JWS Header - `kid` must be a DID URI with a fragment")
}

let signingDIDURI = String(verificationMethodIDParts[0])
if let expectedSigningDIDURI {
guard signingDIDURI == expectedSigningDIDURI else {
// The compactJWS was signed by someone other than the provided `expectedSigningDIDURI`.
// This means that the signature is not valid for what the caller requested.
return false
}
}

let resolutionResult = await DIDResolver.resolve(didURI: signingDIDURI)

if let error = resolutionResult.didResolutionMetadata.error {
throw Error.verifyError("Failed to resolve \(signingDIDURI) - \(error)")
}

guard let verificationMethod = resolutionResult.didDocument?.verificationMethod?.first(
where: { vm in vm.id == verificationMethodID }
)
else {
throw Error.verifyError("No VerificationMethod not found that matches \(verificationMethodID)")
}

guard let publicKey = verificationMethod.publicKeyJwk else {
throw Error.verifyError("VerificationMethod has no `publicKeyJwk`")
}

let base64UrlEncodedSignature = String(parts[2])
let toVerify = "\(base64UrlEncodedHeader).\(base64UrlEncodedPayload)"

return try Crypto.verify(
payload: Data(toVerify.utf8),
signature: try base64UrlEncodedSignature.decodeBase64Url(),
publicKey: publicKey,
jwsAlgorithm: header.algorithm
)
}
}

// MARK: - Errors

extension JWS {
public enum Error: LocalizedError {
case signError(String)
case verifyError(String)

public var errorDescription: String? {
switch self {
case let .signError(reason):
return "Signing Error: \(reason)"
case let .verifyError(reason):
return "Verify Error: \(reason)"
}
}
}
}

// MARK: - Extensions

extension Jwk.Algorithm {

/// Converts a JWK algorithm to a JWS algorithm.
Expand Down
7 changes: 6 additions & 1 deletion Sources/Web5/Dids/BearerDID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ public struct BearerDID {
}

let keyAlias = try keyManager.getDeterministicAlias(key: publicKey)
return BearerDIDSigner(keyAlias: keyAlias, keyManager: keyManager)
return BearerDIDSigner(
keyAlias: keyAlias,
keyManager: keyManager,
verificationMethod: verificationMethod
)
}

/// Exports the `BearerDID` into a portable format that contains the DID's URI in addition
Expand Down Expand Up @@ -137,6 +141,7 @@ public struct BearerDIDSigner {

let keyAlias: String
let keyManager: KeyManager
let verificationMethod: VerificationMethod

public func sign<P>(payload: P) throws -> Data
where P: DataProtocol {
Expand Down
57 changes: 57 additions & 0 deletions Tests/Web5Tests/Crypto/JOSE/JWSTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import XCTest

@testable import Web5

final class JWSTests: XCTestCase {

let did = try! DIDJWK.create(keyManager: InMemoryKeyManager())
let payload = "Hello, World!".data(using: .utf8)!

func test_sign_detachedPayload() throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: true)
let compactJWSParts = compactJWS.split(separator: ".", omittingEmptySubsequences: false)

XCTAssertEqual(compactJWSParts.count, 3)
// Payload (second part) should be empty, as the payload is detached
XCTAssertTrue(compactJWSParts[1].isEmpty)
}

func test_sign_attachedPayload() throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let compactJWSParts = compactJWS.split(separator: ".", omittingEmptySubsequences: false)

XCTAssertEqual(compactJWSParts.count, 3)
// Payload (second part) should NOT be empty, as the payload is attached
XCTAssertFalse(compactJWSParts[1].isEmpty)
}

func test_verify_detachedPayload() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: true)
let isValid = try await JWS.verify(compactJWS: compactJWS, detachedPayload: payload)

XCTAssertTrue(isValid)
}

func test_verify_attachedPayload() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let isValid = try await JWS.verify(compactJWS: compactJWS)

XCTAssertTrue(isValid)
}

func test_verify_expectedSigningDIDURI_match() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let isValid = try await JWS.verify(compactJWS: compactJWS, expectedSigningDIDURI: did.uri)

XCTAssertTrue(isValid)
}

func test_verify_expectedSigningDIDURI_noMatch() async throws {
let compactJWS = try JWS.sign(did: did, payload: payload, detached: false)
let isValid = try await JWS.verify(compactJWS: compactJWS, expectedSigningDIDURI: "did:example:1234")

// compactJWS was signed by `did`, but we're expecting it to be signed by a different DID.
// This should result in an invalid signature.
XCTAssertFalse(isValid)
}
}

0 comments on commit 24a8cf3

Please sign in to comment.