Skip to content

Commit 03994a5

Browse files
committed
Add initial library
1 parent 470f43c commit 03994a5

12 files changed

+369
-0
lines changed

Package.resolved

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// swift-tools-version:5.6
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "PasskeyDemo",
6+
platforms: [
7+
.macOS(.v12)
8+
],
9+
dependencies: [
10+
// 💧 A server-side Swift web framework.
11+
.package(url: "https://github.com/unrelentingtech/SwiftCBOR.git", from: "0.4.5"),
12+
.package(url: "https://github.com/apple/swift-crypto.git", from: "2.0.0"),
13+
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
14+
],
15+
targets: [
16+
.target(
17+
name: "WebAuthn",
18+
dependencies: [
19+
"SwiftCBOR",
20+
.product(name: "Crypto", package: "swift-crypto"),
21+
.product(name: "Logging", package: "swift-log"),
22+
]
23+
),
24+
.testTarget(name: "WebAuthnTests", dependencies: [
25+
.target(name: "WebAuthn"),
26+
])
27+
]
28+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
3+
public struct AssertionCredential: Codable {
4+
public let id: String
5+
public let type: String
6+
public let response: AssertionCredentialResponse
7+
public let rawID: String
8+
9+
enum CodingKeys: String, CodingKey {
10+
case id
11+
case rawID = "rawId"
12+
case type
13+
case response
14+
}
15+
}
16+
17+
public struct AssertionCredentialResponse: Codable {
18+
let authenticatorData: String
19+
let clientDataJSON: String
20+
let signature: String
21+
let userHandle: String
22+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
struct AttestedCredentialData {
2+
let aaguid: [UInt8]
3+
let credentialID: [UInt8]
4+
let publicKey: [UInt8]
5+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
struct AuthenticatorFlags {
2+
3+
/**
4+
Taken from https://w3c.github.io/webauthn/#sctn-authenticator-data
5+
Bit 0: User Present Result
6+
Bit 1: Reserved for future use
7+
Bit 2: User Verified Result
8+
Bits 3-5: Reserved for future use
9+
Bit 6: Attested credential data included
10+
Bit 7: Extension data include
11+
*/
12+
13+
enum Bit: UInt8 {
14+
case userPresent = 0
15+
case userVerified = 2
16+
case attestedCredentialDataIncluded = 6
17+
case extensionDataIncluded = 7
18+
}
19+
20+
let userPresent: Bool
21+
let userVerified: Bool
22+
let attestedCredentialData: Bool
23+
let extensionDataIncluded: Bool
24+
25+
init(_ byte: UInt8) {
26+
userPresent = Self.isFlagSet(on: byte, at: .userPresent)
27+
userVerified = Self.isFlagSet(on: byte, at: .userVerified)
28+
attestedCredentialData = Self.isFlagSet(on: byte, at: .attestedCredentialDataIncluded)
29+
extensionDataIncluded = Self.isFlagSet(on: byte, at: .extensionDataIncluded)
30+
}
31+
32+
static func isFlagSet(on byte: UInt8, at position: Bit) -> Bool {
33+
(byte & (1 << position.rawValue)) != 0
34+
}
35+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import Foundation
2+
3+
struct ClientDataObject: Codable {
4+
let challenge: String
5+
let origin: String
6+
let type: String
7+
}

Sources/WebAuthn/Credential.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Foundation
2+
import Crypto
3+
4+
public struct Credential {
5+
/// base64 encoded String of the credential ID bytes
6+
public let credentialID: String
7+
8+
/// The public key for this certificate
9+
public let publicKey: P256.Signing.PublicKey
10+
}

Sources/WebAuthn/Numbers+Bytes.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
3+
public enum Endian {
4+
case big, little
5+
}
6+
7+
protocol IntegerTransform: Sequence where Element: FixedWidthInteger {
8+
func toInteger<I: FixedWidthInteger>(endian: Endian) -> I
9+
}
10+
11+
extension IntegerTransform {
12+
func toInteger<I: FixedWidthInteger>(endian: Endian) -> I {
13+
let f = { (accum: I, next: Element) in accum &<< next.bitWidth | I(next) }
14+
return endian == .big ? reduce(0, f) : reversed().reduce(0, f)
15+
}
16+
}
17+
18+
extension Data: IntegerTransform {}
19+
extension Array: IntegerTransform where Element: FixedWidthInteger {}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
3+
public struct RegisterWebAuthnCredentialData: Codable {
4+
public let id: String
5+
let rawID: String
6+
let type: String
7+
let response: RegisterCredentialsResponse
8+
9+
enum CodingKeys: String, CodingKey {
10+
case id
11+
case rawID = "rawId"
12+
case type
13+
case response
14+
}
15+
}
16+
17+
public struct RegisterCredentialsResponse: Codable {
18+
let attestationObject: String
19+
let clientDataJSON: String
20+
}

Sources/WebAuthn/WebAuthn.swift

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import SwiftCBOR
2+
import Crypto
3+
import Logging
4+
import Foundation
5+
6+
public enum WebAuthn {
7+
public static func validateAssertion(_ data: AssertionCredential, challengeProvided: String, publicKey: P256.Signing.PublicKey, logger: Logger) throws {
8+
guard let clientObjectData = Data(base64Encoded: data.response.clientDataJSON) else {
9+
throw WebAuthnError.badRequestData
10+
}
11+
let clientObject = try JSONDecoder().decode(ClientDataObject.self, from: clientObjectData)
12+
guard challengeProvided == clientObject.challenge else {
13+
throw WebAuthnError.validationError
14+
}
15+
let clientDataJSONHash = SHA256.hash(data: clientObjectData)
16+
17+
var base64AssertionString = data.response.authenticatorData.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
18+
while base64AssertionString.count % 4 != 0 {
19+
base64AssertionString = base64AssertionString.appending("=")
20+
}
21+
guard let authenticatorData = Data(base64Encoded: base64AssertionString) else {
22+
throw WebAuthnError.badRequestData
23+
}
24+
let signedData = authenticatorData + clientDataJSONHash
25+
26+
var base64SignatureString = data.response.signature.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
27+
while base64SignatureString.count % 4 != 0 {
28+
base64SignatureString = base64SignatureString.appending("=")
29+
}
30+
guard let signatureData = Data(base64Encoded: base64SignatureString) else {
31+
throw WebAuthnError.badRequestData
32+
}
33+
let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureData)
34+
guard publicKey.isValidSignature(signature, for: signedData) else {
35+
throw WebAuthnError.validationError
36+
}
37+
}
38+
39+
public static func parseRegisterCredentials(_ data: RegisterWebAuthnCredentialData, challengeProvided: String, origin: String, logger: Logger) throws -> Credential {
40+
guard let clientObjectData = Data(base64Encoded: data.response.clientDataJSON) else {
41+
throw WebAuthnError.badRequestData
42+
}
43+
let clientObject = try JSONDecoder().decode(ClientDataObject.self, from: clientObjectData)
44+
guard challengeProvided == clientObject.challenge else {
45+
throw WebAuthnError.validationError
46+
}
47+
guard clientObject.type == "webauthn.create" else {
48+
throw WebAuthnError.badRequestData
49+
}
50+
guard origin == clientObject.origin else {
51+
throw WebAuthnError.validationError
52+
}
53+
var base64AttestationString = data.response.attestationObject.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
54+
while base64AttestationString.count % 4 != 0 {
55+
base64AttestationString = base64AttestationString.appending("=")
56+
}
57+
guard let attestationData = Data(base64Encoded: base64AttestationString) else {
58+
throw WebAuthnError.badRequestData
59+
}
60+
guard let decodedAttestationObject = try CBOR.decode([UInt8](attestationData)) else {
61+
throw WebAuthnError.badRequestData
62+
}
63+
logger.debug("Got COBR decoded data: \(decodedAttestationObject)")
64+
65+
// Ignore format/statement for now
66+
guard let authData = decodedAttestationObject["authData"], case let .byteString(authDataBytes) = authData else {
67+
throw WebAuthnError.badRequestData
68+
}
69+
guard let credentialsData = try parseAttestationObject(authDataBytes, logger: logger) else {
70+
throw WebAuthnError.badRequestData
71+
}
72+
guard let publicKeyObject = try CBOR.decode(credentialsData.publicKey) else {
73+
throw WebAuthnError.badRequestData
74+
}
75+
// This is now in COSE format
76+
// https://www.iana.org/assignments/cose/cose.xhtml#algorithms
77+
guard let keyTypeRaw = publicKeyObject[.unsignedInt(1)], case let .unsignedInt(keyType) = keyTypeRaw else {
78+
throw WebAuthnError.badRequestData
79+
}
80+
guard let algorithmRaw = publicKeyObject[.unsignedInt(3)], case let .negativeInt(algorithmNegative) = algorithmRaw else {
81+
throw WebAuthnError.badRequestData
82+
}
83+
// https://github.com/unrelentingtech/SwiftCBOR#swiftcbor
84+
// Negative integers are decoded as NegativeInt(UInt), where the actual number is -1 - i
85+
let algorithm: Int = -1 - Int(algorithmNegative)
86+
87+
// Curve is key -1 - or -0 for SwiftCBOR
88+
// X Coordinate is key -2, or NegativeInt 1 for SwiftCBOR
89+
// Y Coordinate is key -3, or NegativeInt 2 for SwiftCBOR
90+
91+
guard let curveRaw = publicKeyObject[.negativeInt(0)], case let .unsignedInt(curve) = curveRaw else {
92+
throw WebAuthnError.badRequestData
93+
}
94+
guard let xCoordRaw = publicKeyObject[.negativeInt(1)], case let .byteString(xCoordinateBytes) = xCoordRaw else {
95+
throw WebAuthnError.badRequestData
96+
}
97+
guard let yCoordRaw = publicKeyObject[.negativeInt(2)], case let .byteString(yCoordinateBytes) = yCoordRaw else {
98+
throw WebAuthnError.badRequestData
99+
}
100+
101+
logger.debug("Key type was \(keyType)")
102+
logger.debug("Algorithm was \(algorithm)")
103+
logger.debug("Curve was \(curve)")
104+
105+
let key = try P256.Signing.PublicKey(rawRepresentation: xCoordinateBytes + yCoordinateBytes)
106+
logger.debug("Key is \(key.pemRepresentation)")
107+
108+
return Credential(credentialID: data.id, publicKey: key)
109+
}
110+
111+
static func parseAttestedData(_ data: [UInt8], logger: Logger) throws -> AttestedCredentialData {
112+
// We've parsed the first 37 bytes so far, the next bytes now should be the attested credential data
113+
// See https://w3c.github.io/webauthn/#sctn-attested-credential-data
114+
let aaguidLength = 16
115+
let aaguid = data[37..<(37 + aaguidLength)] // To byte at index 52
116+
117+
let idLengthBytes = data[53..<55] // Length is 2 bytes
118+
let idLengthData = Data(idLengthBytes)
119+
let idLength: UInt16 = idLengthData.toInteger(endian: .big)
120+
let credentialIDEndIndex = Int(idLength) + 55
121+
122+
let credentialID = data[55..<credentialIDEndIndex]
123+
let publicKeyBytes = data[credentialIDEndIndex...]
124+
125+
return AttestedCredentialData(aaguid: Array(aaguid), credentialID: Array(credentialID), publicKey: Array(publicKeyBytes))
126+
}
127+
128+
static func parseAttestationObject(_ bytes: [UInt8], logger: Logger) throws -> AttestedCredentialData? {
129+
let minAuthDataLength = 37
130+
let minAttestedAuthLength = 55
131+
let maxCredentialIDLength = 1023
132+
// What to do when we don't have this
133+
var credentialsData: AttestedCredentialData? = nil
134+
135+
guard bytes.count >= minAuthDataLength else {
136+
throw WebAuthnError.authDataTooShort
137+
}
138+
139+
let rpIDHashData = bytes[..<32]
140+
let flags = AuthenticatorFlags(bytes[32])
141+
let counter: UInt32 = Data(bytes[33..<37]).toInteger(endian: .big)
142+
143+
var remainingCount = bytes.count - minAuthDataLength
144+
145+
if flags.attestedCredentialData {
146+
guard bytes.count > minAttestedAuthLength else {
147+
throw WebAuthnError.attestedCredentialDataMissing
148+
}
149+
let attestedCredentialData = try parseAttestedData(bytes, logger: logger)
150+
// 2 is the bytes storing the size of the credential ID
151+
let credentialDataLength = attestedCredentialData.aaguid.count + 2 + attestedCredentialData.credentialID.count + attestedCredentialData.publicKey.count
152+
remainingCount -= credentialDataLength
153+
credentialsData = attestedCredentialData
154+
} else {
155+
if !flags.extensionDataIncluded && bytes.count != minAuthDataLength {
156+
throw WebAuthnError.attestedCredentialFlagNotSet
157+
}
158+
}
159+
160+
if flags.extensionDataIncluded {
161+
guard remainingCount != 0 else {
162+
throw WebAuthnError.extensionDataMissing
163+
}
164+
let extensionData = bytes[(bytes.count - remainingCount)...]
165+
remainingCount -= extensionData.count
166+
}
167+
168+
guard remainingCount == 0 else {
169+
throw WebAuthnError.leftOverBytes
170+
}
171+
return credentialsData
172+
}
173+
}

0 commit comments

Comments
 (0)