Offers a way to support more content protection technologies in Readium 2.
DRMs and other protection technologies can be vastly different, therefore we don’t want to make a generic system fitting the needs of every arbitrary DRM. Instead, we should only focus on the features that are actually used in Readium components, such as resource decryption and rights consumption.
We want to:
Peripheral features should be handled by reading apps themselves, which control the supported DRMs, for example:
A Content Protection can be different things:
Since it’s usually possible to read the metadata of a publication without unlocking its protection, the credentials are not always necessary. If the provided credentials are incorrect or missing, the Publication
can be returned in a restricted state: parts of its manifest are readable, but not all of its resources.
However, if you need to render the publication to the user, you can set the allowUserInteraction
parameter to true
. If the given credentials are incorrect, then the Content Protection will be allowed to ask the user for its credentials.
streamer.open(asset, allowUserInteraction = true, credentials = "open sesame")
Some Content Protections – e.g. ZIP encryption – prevent reading a publication’s metadata even in a restricted state without the proper credentials. In this case, a Publication.OpeningError.IncorrectCredentials
error will be returned.
Format-specific protections are natively handled by the Streamer, since they are tied to the asset format. However, for third-party technologies such as a DRM, you need to register them by providing a ContentProtection
instance to the Streamer. Here’s an example for LCP:
streamer = Streamer(
contentProtections: [
// The provided `authentication` argument is part of the LCP library
// and is used to retrieve the user passphrase when needed.
lcpService.contentProtection(authentication)
]
)
To render the publication, reading apps must first check if it is restricted. Navigators must refuse to be created with a restricted publication.
if (!publication.isRestricted) {
presentNavigator(publication)
}
Some Content Protection technologies support user rights, such as copy or print. It’s possible to consume these rights using the UserRights
API, for example, to copy a text selection:
if (publication.rights.copy(text)) {
pasteboard.add(text)
}
Sometimes, you need to know whether the copy action is allowed before actually consuming the right, for example to know if you can display a sharing popup. In which case, you can use canCopy(text: String)
. For a more general purpose, the property canCopy
indicates whether the action is at all possible and can be used to grey out a “Copy” button.
This new API is not backward-compatible with the current support for LCP and reading apps will need to update their integration.
The Readium toolkit offers different tools to address the various Content Protection technologies:
Fetcher
, for access restrictions.ContentProtectionService
attached to the Publication
, to manage user rights and hold additional DRM-specific extensions, such as a license object.Content Protections can be grouped in two categories:
Readium supports only a single enabled Content Protection per publication, because cumulating rights consumption or decryption from different sources can’t be done blindly. Third-party protections provided to the Streamer take precedence (in order) over any format-specific protection.
There are currently only two format-specific protections recognized by Readium: PDF and ZIP.
When a password protection is used, the credentials
parameter provided to the Streamer is used to unlock the protected asset. In case of incorrect credentials:
allowUserInteraction
is true
, then the onAskCredentials()
callback provided to the Streamer is used to request the password.IncorrectCredentials
error is returned because format-specific protections don’t support reading partial metadata.There are many encryption methods supported in ZIP, some proprietary. Readium can’t reasonably handle all of it, and the supported protections will depend on the underlying ZIP library used. Traditional PKWARE encryption, even though not really secure, is widely used to add password protection to a ZIP, and should therefore be supported if possible.
PDF supports encryption protected by password and user permissions.
If a PDF contains user permissions, such as “copy”, then the Streamer will expose them through a UserRights
instance attached to the ContentProtectionService
. Only a subset of PDF permissions are supported: the ones used in Readium. For example, the “changes allowed” permission doesn’t make sense since we can’t edit a PDF in Readium.
Note that Readium supports only publication-level protections. If a ZIP package contains a password-protected PDF file, then the PDF won’t be readable in Readium.
Third-party protections can be widely different, therefore the Readium toolkit avoids making assumptions about the way they work. This means that only the core features used in Readium – publication locking, resources transformation and rights consumption – need to be implemented with protection-agnostic interfaces. Any peripheral features, such as managing loans or presenting license information, are out of scope for Readium. They should be handled by reading apps themselves, using the APIs provided by the Content Protection library.
A third-party protection library (or bridge) should implement the ContentProtection
interface, which will be registered to the Streamer and used when parsing a publication.
A protected publication can be opened in two states: restricted or unrestricted. A restricted publication has a limited access to its manifest and resources and can’t be rendered with a Navigator. It is usually only used to import a publication to the user’s bookshelf.
Readium makes no assumption about the way a third-party protection can unlock a publication. It could for example:
onAskCredentials()
callback provided to the Streamer.ContentProtection
is allowed to prompt the user for its credentials only if the allowUserInteraction
parameter is set to true. The rationale is that a reading app might want to import a collection of publications, in which case the user should not be asked for all its credentials. However, background requests are allowed at all time.
Note that if, for a given third-party protection, a restricted publication can’t be used to create its Manifest
at all, then the parsing should fail with an IncorrectCredentials
error, like with a password-protected ZIP.
r2-shared
)UserRights
InterfaceManages consumption of user rights and permissions.
canCopy: Boolean
false
if the copy right is all consumed.canCopy(text: String) -> Bool
text
to the pasteboard.canCopy
property, and can return false
if the given text
exceeds the allowed amount of characters to copy.copy(text: String) -> Bool
canPrint: Boolean
false
if the print right is all consumed.canPrint(pageCount: Int) -> Bool
canPrint
property, and can return false
if the given pageCount
exceeds the allowed amount of pages to print.print(pageCount: Int) -> Bool
UnrestrictedUserRights
AllRestrictedUserRights
Publication
opened in a restricted state. It will forbid all user rights.ContentProtectionService
Interface (implements Publication.Service
)Provides information about a publication’s content protection and manages user rights.
isRestricted: Boolean
Publication
has a restricted access to its resources and can’t be rendered in a Navigator.error: Error?
Publication
, if any.Publication
with a Navigator.credentials: String?
Publication
.Streamer::open()
.rights: UserRights
name: LocalizedString?
"Protected by {name}"
Publication
HelpersisProtected: Boolean
Publication
is protected by a Content Protection technology.ContentProtectionService
attached to the Publication
.isRestricted: Boolean
false
.protectionError: Error?
null
.credentials: String?
null
.rights: UserRights
UnrestrictedUserRights()
.protectionLocalizedName: LocalizedString?
null
.protectionName: String?
null
.content-protection
Route/~readium/content-protection
application/vnd.readium.content-protection+json
{
"isRestricted": false,
"error": {
"en": "The publication has expired."
},
"name": {
"en": "Readium LCP"
},
"rights": {
"canCopy": true,
"canPrint": false
}
}
rights/copy
Route/~readium/rights/copy{?text,peek}
text
is the percent-encoded string to copy.peek
is true
or false
. When missing, it defaults to false
.application/vnd.readium.rights.copy+json
If peek
is true, then it’s equivalent to calling publication.rights.canCopy(...)
: the right is not consumed.
Response
Status Code | Description |
---|---|
200 |
The copy is allowed. |
403 |
The copy is forbidden. |
rights/print
Route/~readium/rights/print{?pageCount,peek}
pageCount
is the number of pages to print, as a positive integer.peek
is true
or false
. When missing, it defaults to false
.application/vnd.readium.rights.print+json
If peek
is true, then it’s equivalent to calling publication.rights.canPrint(...)
: the right is not consumed.
Response
Status Code | Description |
---|---|
200 |
The print is allowed. |
403 |
The print is forbidden. |
r2-streamer
)Streamer
ExtensionsConstructor
Two new arguments are added to the constructor: contentProtections
and onAskCredentials
.
contentProtections: List<ContentProtection> = []
ContentProtection
used to unlock publications.ContentProtection
is tested in the given order.onAskCredentials: OnAskCredentialsCallback? = default
typealias OnAskCredentialsCallback = (dialog: Dialog<String>, sender: Any?, callback: (String?) -> ()) -> ()
dialog: Dialog<String>
Dialog<T>
is out of scope for this proposal.sender: Any?
Streamer::open()
as context.Activity
/ViewController
which would be used to present the dialog.callback: (String?) -> ()
Methods
There are three new parameters added to Streamer::open()
: allowUserInteraction
, credentials
, and sender
.
open(asset: PublicationAsset, allowUserInteraction: Boolean, credentials: String? = null, sender: Any? = null, onCreatePublication: Publication.Builder.Transform? = null, warnings: WarningLogger? = null) -> Publication
allowUserInteraction: Boolean
true
when you want to render a publication in a Navigator.false
, Content Protections are allowed to do background requests, but not to present a UI to the user.credentials: String? = null
sender: Any? = null
Activity
/ViewController
which would be used to present a credentials dialog.ContentProtection
InterfaceBridge between a Content Protection technology and the Readium toolkit.
Its responsibilities are to:
Fetcher
.ContentProtectionService
publication service.open(asset: PublicationAsset, fetcher: Fetcher, allowUserInteraction: Boolean, credentials: String?, sender: Any?, onAskCredentials: OnAskCredentialsCallback?) -> ProtectedAsset?
fetcher: Fetcher
Fetcher
for the low-level asset access (e.g. ArchiveFetcher
for a ZIP archive), to avoid having each Content Protection open the asset
to check if it’s protected or not.Fetcher
.ProtectedAsset
in case of success,null
if the asset
is not protected by this technology,Publication.OpeningError
if the asset can’t be successfully opened.ProtectedAsset
ClassHolds the result of opening a PublicationAsset
with a ContentProtection
.
All the constructor parameters are public.
ProtectedAsset(asset: PublicationAsset, fetcher: Fetcher, onCreatePublication: Publication.Builder.Transform?)
asset: PublicationAsset
asset
provided to ContentProtection::open()
, but a Content Protection might modify it in some cases:
asset
with the matching unprotected media type.fetcher: Fetcher
Fetcher
provided to ContentProtection::open()
, for example by:
fetcher
in a TransformingFetcher
with a decryption Resource.Transformer
function.fetcher
altogether and creating a new one to handle access restrictions. For example, by creating an HTTPFetcher
which will inject a Bearer Token in requests.onCreatePublication: Publication.Builder.Transform?
While the presented protection features are only limited to the ones used by Readium components, the architecture is opened to third-party extensions.
ContentProtectionService
is a place of choice for extensibility, because it is attached to the Publication
object. You can add any extra features and, optionally, expose them as publication helpers.
Here’s an example of how to expose the license of the LCP DRM inside the Publication
:
class LCPContentProtectionService: ContentProtectionService {
let license: LCPLicense
}
extension Publication {
var lcpLicense: LCPLicense? {
findService(ContentProtectionService.self)?.license
}
}
// Then the reading app can access the license from the `Publication` itself:
publication.lcpLicense?.renew()
You can also add global features in your implementation of the ContentProtection
interface. This class is instanciated by reading apps, so you have full control over the exposed API.
A common use case would be to require the reading app to implement a custom callback interface to ask the user for the credentials when requested. Here’s an example with LCP:
interface LCPAuthenticating {
requestPassphrase(license: LCPAuthenticatedLicense): String?
}
class LCPContentProtection extends ContentProtection {
authentication: LCPAuthenticating
constructor(authentication: LCPAuthenticating) {
this.authentication = authentication
}
}
Compared to the current implementation, a Publication
is opened either unrestricted, or restricted. There’s no way to unlock an existing Publication
without reparsing it. The reasons for this choice are:
Publication
after it’s opened:
Peripheral features were initially considered to be integrated in a DRM-agnostic interface: displaying rights information, renewing loans, etc. However, it was guided by the LCP specification, and it became clear that it wouldn’t really be agnostic.
For example, LCP is limiting copy by a character count. But a DRM could restrict word count, or number of copies. Therefore, it’s not easy to display localized information about the remaining copy right available.
Besides, these features don’t live in Readium components but in reading apps. You are welcome to create your own DRM-agnostic adapter for such features.
A Content Protection technology might alter the publication asset during the lifetime of the Publication
object. For example, by injecting an updated license file after renewing a loan. This could potentially be an issue for opened file handlers in the leaf Fetcher
accessing the publication asset.
A solution would be the introduction of an InvalidatingFetcher
, which would be able to recreate its child Fetcher
on-demand. The ContentProtectionService
would keep a reference to this InvalidatingFetcher
, and call invalidate()
every time the publication asset is updated.
InvalidatingFetcher
ClassInvalidatingFetcher(fetcher: Fetcher, invalidate: (Fetcher) -> Fetcher)
fetcher: Fetcher
invalidate: (Fetcher) -> Fetcher
invalidate()
close()
will be called on the previous child fetcher, after calling the invalidate
closure to create the new one.