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

Commit

Permalink
Merge pull request #94 from readium/fix/lcp-decryptor
Browse files Browse the repository at this point in the history
Fix LCP decryption of ranges
  • Loading branch information
mickael-menu authored Oct 19, 2020
2 parents 3622fb2 + 738d300 commit 352f9b2
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 91 deletions.
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)
}

}
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.

0 comments on commit 352f9b2

Please sign in to comment.