Skip to content
This repository has been archived by the owner on Aug 12, 2022. It is now read-only.

Fix LCP decryption of ranges #94

Merged
merged 3 commits into from
Oct 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 182 additions & 17 deletions r2-lcp-swift.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

81 changes: 45 additions & 36 deletions readium-lcp-swift/Content Protection/LCPDecryptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class LCPDecryptor {
enum Error: Swift.Error {
case emptyDecryptedData
case invalidCBCData
case invalidRange(Range<UInt64>)
case inflateFailed
}

Expand Down Expand Up @@ -102,9 +103,11 @@ final class LCPDecryptor {
throw LCPDecryptor.Error.emptyDecryptedData
}

let paddingSize = UInt64(data.last ?? 0)

return length
- AESBlockSize // Minus IV or previous block
- (AESBlockSize - UInt64(data.count)) % AESBlockSize // Minus padding part
- paddingSize // Minus padding part
}
}
}()
Expand All @@ -114,43 +117,36 @@ final class LCPDecryptor {
return license.decryptFully(data: resource.read(), isDeflated: resource.link.isDeflated)
}

return resource.length.tryFlatMap { totalLength in
let length = range.upperBound - range.lowerBound
let blockPosition = range.lowerBound % AESBlockSize

// For beginning of the cipher text, IV used for XOR.
// For cipher text in the middle, previous block used for XOR.
let readPosition = range.lowerBound - blockPosition

// Count blocks to read.
// First block for IV or previous block to perform XOR.
var blocksCount: UInt64 = 1
var bytesInFirstBlock = (AESBlockSize - blockPosition) % AESBlockSize
if (length < bytesInFirstBlock) {
bytesInFirstBlock = 0
}
if (bytesInFirstBlock > 0) {
blocksCount += 1
return resource.length.tryFlatMap { encryptedLength in
guard let rangeFirst = range.first, let rangeLast = range.last else {
throw LCPDecryptor.Error.invalidRange(range)
}

blocksCount += (length - bytesInFirstBlock) / AESBlockSize
if (length - bytesInFirstBlock) % AESBlockSize != 0 {
blocksCount += 1
}

let readSize = blocksCount * AESBlockSize

return resource.read(range: readPosition..<(readPosition + readSize))
.tryMap { encryptedData in
guard var data = try license.decipher(encryptedData) else {
throw LCPDecryptor.Error.emptyDecryptedData
}

if (data.count > length) {
data = data[0..<length]
}

return data
// Encrypted data is shifted by AESBlockSize, because of IV and because the
// previous block must be provided to perform XOR on intermediate blocks.
let encryptedStart = rangeFirst.floorMultiple(of: AESBlockSize)
let encryptedEndExclusive = (rangeLast + 1).ceilMultiple(of: AESBlockSize) + AESBlockSize

return resource.read(range: encryptedStart..<encryptedEndExclusive).tryMap { encryptedData in
guard let bytes = try license.decipher(encryptedData) else {
throw LCPDecryptor.Error.emptyDecryptedData
}

// Exclude the bytes added to match a multiple of AESBlockSize.
let sliceStart = (rangeFirst - encryptedStart)

let isLastBlockRead = encryptedLength - encryptedEndExclusive <= AESBlockSize
let rangeLength = isLastBlockRead
// Use decrypted length to ensure `rangeLast` doesn't exceed decrypted length - 1.
? min(rangeLast, try length.get() - 1) - rangeFirst + 1
// The last block won't be read, so there's no need to compute the length
: rangeLast - rangeFirst + 1

// Keep only enough bytes to fit the length-corrected request in order to never
// include padding.
let sliceEnd = sliceStart + rangeLength

return bytes[sliceStart..<sliceEnd]
}
}
}
Expand Down Expand Up @@ -199,3 +195,16 @@ private extension R2Link {
}

}


private extension UInt64 {

func ceilMultiple(of divisor: UInt64) -> UInt64 {
divisor * (self / divisor + ((self % divisor == 0) ? 0 : 1))
}

func floorMultiple(of divisor: UInt64) -> UInt64 {
divisor * (self / divisor)
}

}
33 changes: 33 additions & 0 deletions readium-lcp-swift/LCPAuthenticating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,36 @@ public struct LCPAuthenticatedLicense {
}

}

/// An `LCPAuthenticating` implementation which can directly use a provided clear or hashed
/// passphrase.
///
/// If the provided `passphrase` is incorrect, the given `fallback` authentication is used.
public class LCPPassphrase: LCPAuthenticating {

private let passphrase: String
private let fallback: LCPAuthenticating?

public init(_ passphrase: String, fallback: LCPAuthenticating? = nil) {
self.passphrase = passphrase
self.fallback = fallback
}

public var requiresUserInteraction: Bool {
fallback?.requiresUserInteraction ?? false
}

public func requestPassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, sender: Any?, completion: @escaping (String?) -> Void) {
guard reason == .passphraseNotFound else {
if let fallback = fallback {
fallback.requestPassphrase(for: license, reason: reason, sender: sender, completion: completion)
} else {
completion(nil)
}
return
}

completion(passphrase)
}

}
19 changes: 5 additions & 14 deletions readium-lcp-swift/License/Model/Components/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,14 @@ public struct Link {

/// Gets the valid URL if possible, applying the given template context as query parameters if the link is templated.
/// eg. http://url{?id,name} + [id: x, name: y] -> http://url?id=x&name=y
func url(with parameters: [String: CustomStringConvertible]) -> URL? {
if !templated {
return URL(string: href)
}

let urlString = href.replacingOccurrences(of: "\\{\\?.+?\\}", with: "", options: [.regularExpression])

guard var urlBuilder = URLComponents(string: urlString) else {
return nil
}
func url(with parameters: [String: LosslessStringConvertible]) -> URL? {
var href = self.href

// Add the template context as query parameters
urlBuilder.queryItems = parameters.map { param in
URLQueryItem(name: param.key, value: param.value.description)
if templated {
href = URITemplate(href).expand(with: parameters.mapValues { String(describing: $0) })
}

return urlBuilder.url
return URL(string: href)
}

/// Expands the href without any template context.
Expand Down
2 changes: 1 addition & 1 deletion readium-lcp-swift/License/Model/LicenseDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public struct LicenseDocument {

/// Gets and expands the URL for the given rel, if it exits.
/// - Throws: `LCPError.invalidLink` if the URL can't be built.
func url(for rel: Rel, with parameters: [String: CustomStringConvertible] = [:]) throws -> URL {
func url(for rel: Rel, with parameters: [String: LosslessStringConvertible] = [:]) throws -> URL {
guard let url = link(for: rel)?.url(with: parameters) else {
throw ParsingError.url(rel: rel.rawValue)
}
Expand Down
2 changes: 1 addition & 1 deletion readium-lcp-swift/License/Model/StatusDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public struct StatusDocument {

/// Gets and expands the URL for the given rel, if it exits.
/// - Throws: `LCPError.invalidLink` if the URL can't be built.
func url(for rel: Rel, with parameters: [String: CustomStringConvertible] = [:]) throws -> URL {
func url(for rel: Rel, with parameters: [String: LosslessStringConvertible] = [:]) throws -> URL {
guard let url = link(for: rel)?.url(with: parameters) else {
throw ParsingError.url(rel: rel.rawValue)
}
Expand Down
83 changes: 83 additions & 0 deletions readium-lcp-swiftTests/Content Protection/LCPDecryptorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// Copyright 2020 Readium Foundation. All rights reserved.
// Use of this source code is governed by the BSD-style license
// available in the top-level LICENSE file of the project.
//

import XCTest
import R2Shared
import PDFKit
@testable import ReadiumLCP

class LCPDecryptorTests: XCTestCase {

let fixtures = Fixtures()
var service: LCPService!
var encryptedResource: Resource!
var clearData: Data!

override func setUpWithError() throws {
service = R2MakeLCPService()

let fetcher = ArchiveFetcher(archive: try DefaultArchiveFactory().open(url: self.fixtures.url(for: "daisy.lcpdf"), password: nil))
encryptedResource = fetcher.get(Link(
href: "/publication.pdf",
properties: Properties([
"encrypted": [
"scheme": "http://readium.org/2014/01/lcp",
"profile": "http://readium.org/lcp/basic-profile",
"algorithm": "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
]
])
))

clearData = try Data(contentsOf: fixtures.url(for: "daisy.pdf"))
}

/// Checks that we can decrypt the full content successfully.
func testDecryptFull() throws {
retrieveLicense(path: "daisy.lcpdf", passphrase: "test") { license in
let decryptedResource = LCPDecryptor(license: license).decrypt(resource: self.encryptedResource)

XCTAssertEqual(try decryptedResource.read().get(), self.clearData)
}
}

/// Checks that we can decrypt various ranges successfully.
func testDecryptRanges() throws {
retrieveLicense(path: "daisy.lcpdf", passphrase: "test") { license in
let decryptedResource = LCPDecryptor(license: license).decrypt(resource: self.encryptedResource)

// These ranges seem arbirtrary, but some of them were failing before the fix in the
// same commit.
let ranges: [Range<UInt64>] = [
0..<2048, // 2048
817152..<819200, // 2048
819200..<819856, // 656
0..<16384, // 16384
819792..<819856, // 64
819565..<819856 // 291
]

for range in ranges {
let intRange = Int(range.lowerBound)..<Int(range.upperBound)
let decrypted = try decryptedResource.read(range: range).get()
let clear = self.clearData[intRange]
XCTAssertEqual(decrypted, clear, "Failed to decrypt range \(intRange)")
}
}
}

private func retrieveLicense(path: String, passphrase: String, completion: @escaping (LCPLicense) throws -> Void) {
let completionExpectation = expectation(description: "License opened")

let url = fixtures.url(for: path)
service.retrieveLicense(from: url, authentication: LCPPassphrase(passphrase)) { result in
try! completion((try! result.get())!)
completionExpectation.fulfill()
}

waitForExpectations(timeout: 10, handler: nil)
}

}
32 changes: 32 additions & 0 deletions readium-lcp-swiftTests/Fixtures.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Copyright 2020 Readium Foundation. All rights reserved.
// Use of this source code is governed by the BSD-style license
// available in the top-level LICENSE file of the project.
//

import Foundation
import XCTest

class Fixtures {

let path: String?

init(path: String? = nil) {
self.path = path
}

private lazy var bundle = Bundle(for: type(of: self))

func url(for filepath: String) -> URL {
return try! XCTUnwrap(bundle.resourceURL?.appendingPathComponent("Fixtures/\(path ?? "")/\(filepath)"))
}

func data(at filepath: String) -> Data {
return try! XCTUnwrap(try? Data(contentsOf: url(for: filepath)))
}

func json<T>(at filepath: String) -> T {
return try! XCTUnwrap(JSONSerialization.jsonObject(with: data(at: filepath)) as? T)
}

}
Binary file added readium-lcp-swiftTests/Fixtures/daisy.lcpdf
Binary file not shown.
Binary file added readium-lcp-swiftTests/Fixtures/daisy.pdf
Binary file not shown.
4 changes: 2 additions & 2 deletions readium-lcp-swiftTests/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
Expand All @@ -13,7 +13,7 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

36 changes: 0 additions & 36 deletions readium-lcp-swiftTests/readium_lcp_swiftTests.swift

This file was deleted.