Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
trust-format/** text eol=lf
4 changes: 4 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ jobs:

- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
# the action defaults to `tools platform-tools`, and Google has removed
# the obsolete `tools` package, so sdkmanager exits 1 trying to find it
packages: 'platform-tools'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ room = "2.7.0"
work = "2.10.0"
media3 = "1.10.1"
kotlinxDatetime = "0.8.0"
bouncycastle = "1.85"
bouncycastle = "1.86"
sol4k = "0.7.0"
zxing = "3.5.4"

Expand Down
35 changes: 35 additions & 0 deletions sdk/trust/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,42 @@ kotlin {
}
}

sourceSets.main {
resources.srcDir(rootProject.file("trust-format"))
}

sourceSets.test {
resources.srcDir(rootProject.file("signer/tests/vectors"))
}

val checkTrustIsolation by tasks.registering {
doLast {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of scope for this PR and probably not the biggest deal since we're gonna own/run this but I think we should move to detekt for stuff like this. Considering moving some of the build plugin to use that as well.

fileTree("src/main").matching { include("**/*.kt", "**/*.java") }.forEach {
check(!Regex("\\b(?:Security\\s*\\.\\s*(?:addProvider|insertProviderAt)|BouncyCastleProvider)\\b").containsMatchIn(it.readText())) {
"Use BouncyCastle lightweight APIs without provider registration: $it"
}
check(!Regex("(?m)^\\s*import\\s+(?:static\\s+)?android\\.").containsMatchIn(it.readText())) {
"Android import in trust main sources: $it"
}
}
configurations.forEach { configuration ->
configuration.dependencies.forEach { dependency ->
check(dependency.name != "apksig" || configuration.name == "testImplementation") {
"apksig is only allowed in testImplementation"
}
}
}
listOf("compileClasspath", "runtimeClasspath").forEach { name ->
check(configurations.getByName(name).resolvedConfiguration.resolvedArtifacts.none { it.name == "apksig" }) {
"apksig must not enter the production dependency graph"
}
}
}
}
tasks.named("check") { dependsOn(checkTrustIsolation) }

dependencies {
implementation(libs.bouncycastle.provider)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlin.test)
testImplementation(libs.apksig)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package com.thelightphone.sdk.trust

import com.thelightphone.sdk.trust.LightInstallDecision.Allow
import com.thelightphone.sdk.trust.LightInstallDecision.Deny
import com.thelightphone.sdk.trust.LightInstallDecision.Kill

/**
* Local mirror of the Android-owned `ClientFilterLevel`, which this module cannot import:
* `:sdk:server` is an Android library, and Gradle will not hand an `androidJvm` variant to
* a `jvm` consumer. Subtask 07 also makes `:sdk:server` depend on this module, so the
* reverse edge would be a cycle.
*
* The constants are named identically so the mapping in 07 stays a rename-free `when`.
* Collapsing the two into one enum in `:sdk:shared` is deferred — it touches published API.
*/
enum class LightTrustFilterLevel {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe out of scope for this PR, but rather than copy - let's move this into :sdk:shared and have this module pull that one in? I like the name LightTrustFilterLevel more than ClientFilterLevel anyways, let's just use that everywhere.

ExcludeAllApks,
AllowLightApprovedApks,
AllowLightSignedApks,
AllowAllApks,
}

/** Closed so callers assert on *why* an APK was refused and render a stable UI key. */
enum class DenyReason {
/** No Light stamp, or no trust statement: not something the SDK produced. */
NotLightBuilt,

/** A stamp that does not verify, or a verifier that could not run. */
BadAttestation,

/** The statement describes a different APK than the one being installed. */
StatementNotForThisApk,

/** The statement and the manifest disagree about `versionCode`. */
VersionCodeMismatch,

/** The level admits no third-party APKs at all. */
FilteredOut,

/** No approval for this tool, or none covering this artifact. */
NotApproved,

/** Superseded by the approval's version floor. */
BelowMinVersion,

/** The tool is approved, but under a different app key than this APK carries. */
SignerNotApprovedForTool,
}

sealed interface LightInstallDecision {
data object Allow : LightInstallDecision
data class Deny(val reason: DenyReason) : LightInstallDecision
data class Kill(val action: BlockAction, val reason: String) : LightInstallDecision
}

object LightInstallPolicy {
/**
* Pure method that answers "can this APK be installed?"
*
* `AllowAllApks` installs anything without consulting the rules.
* To tell a user what a stricter level would have done with the same APK,
* call this again with that level.
* The reason is in the returned `Deny` or `Kill`.
*/
fun decide(
stamp: StampResult,
statement: LightTrustStatement?,
signerSha256: String,
apkSha256: () -> String,
manifestVersionCode: Long,
bundle: LightTrustBundle?,
level: LightTrustFilterLevel,
): LightInstallDecision {
// hashing the APK is the most expensive computation in this method,
// and it's used for both block and approve logic
val lazyApkSha256 = lazy(LazyThreadSafetyMode.NONE, apkSha256)

// allowing all apks bypasses every check
if (level == LightTrustFilterLevel.AllowAllApks) {
return Allow
}

// a blocked tool is killed however well-formed it is, so check it before anything.
// `toolId` and `versionCode` come from a statement nothing has authenticated yet.
// safe in one direction only: approval is read after the stamp check,
// so a forged `toolId` can kill its own APK but can never buy an install
blockMatching(bundle, statement, signerSha256, lazyApkSha256)?.let {
return Kill(it.action, it.reason)
}

when (stamp) {
is StampResult.Verified -> Unit
// a self-signed build carries no stamp
StampResult.NotPresent -> return Deny(DenyReason.NotLightBuilt)
// edited after signing, re-signed by someone else, or unverifiable
StampResult.NotVerified,
is StampResult.Unavailable -> return Deny(DenyReason.BadAttestation)
}

// past this point the stamp has been verified so the statement can be trusted
if (statement == null) {
return Deny(DenyReason.NotLightBuilt)
}

// signerSha256 is the cert hash reported by Android,
// and the hash baked in the statement should match it
if (statement.signerSha256 != signerSha256) {
return Deny(DenyReason.StatementNotForThisApk)
}

// checking if version in the statement matches the manifest
if (statement.tool.versionCode != manifestVersionCode) {
return Deny(DenyReason.VersionCodeMismatch)
}

return when (level) {
LightTrustFilterLevel.AllowLightSignedApks -> Allow
LightTrustFilterLevel.ExcludeAllApks -> Deny(DenyReason.FilteredOut)
LightTrustFilterLevel.AllowLightApprovedApks ->
checkApproval(statement, signerSha256, lazyApkSha256, bundle)
LightTrustFilterLevel.AllowAllApks -> Allow
}
}

private fun checkApproval(
statement: LightTrustStatement,
signerSha256: String,
apkSha256: Lazy<String>,
bundle: LightTrustBundle?,
): LightInstallDecision {
val approval = bundle?.allow?.firstOrNull { it.toolId == statement.tool.id }
if (approval == null) {
return Deny(DenyReason.NotApproved)
}

if (approval.signerSha256 != signerSha256) {
return Deny(DenyReason.SignerNotApprovedForTool)
}

// a fresh install can't take a tool back to a superseded version
if (statement.tool.versionCode < approval.minVersionCode) {
return Deny(DenyReason.BelowMinVersion)
}

// `buildId` comes with the statement, so it is free to compare
val artifacts = approval.approvedArtifacts
if (artifacts.any { it is ApprovedArtifact.BuildId && it.value == statement.buildId }) {
return Allow
}

// only now is the full apk hash worth paying for
if (artifacts.any { it is ApprovedArtifact.ApkSha256 && it.value == apkSha256.value }) {
return Allow
}

return Deny(DenyReason.NotApproved)
}

/**
* Checks on the trust bundle if an apk is blocked.
* The trust bundle can match on different parameters, and we just need one of them to match.
**/
private fun blockMatching(
bundle: LightTrustBundle?,
statement: LightTrustStatement?,
signerSha256: String,
apkSha256: Lazy<String>,
): TrustBlock? {
val blocks = bundle?.block.orEmpty()
val toolId = statement?.tool?.id
val versionCode = statement?.tool?.versionCode

val matchedOnIdentity =
blocks.firstOrNull {
when (val match = it.match) {
// block all apks signed by a given key
is BlockMatch.SignerSha256 -> match.value == signerSha256
// block all versions of a tool
is BlockMatch.ToolId -> match.value == toolId
// block a specific version of a tool
is BlockMatch.ToolVersion ->
match.toolId == toolId && match.versionCode == versionCode
// match apk hash in a separate loop to avoid unnecessary hash computation
is BlockMatch.ApkSha256 -> false
}
}
if (matchedOnIdentity != null) {
return matchedOnIdentity
}

// after all metadata matches are exhausted, try to match by apk hash
return blocks.firstOrNull {
it.match is BlockMatch.ApkSha256 && it.match.value == apkSha256.value
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.thelightphone.sdk.trust

data class LightTrustBundle(
val schemaVersion: Int,
val version: Long,
val issuedAt: String,
val allow: List<TrustApproval>,
val block: List<TrustBlock>,
val trustedStampCerts: Set<String>,
val revokedStampCerts: Set<String>,
)

data class TrustApproval(val toolId: String, val signerSha256: String, val minVersionCode: Long, val approvedArtifacts: List<ApprovedArtifact>)
sealed interface ApprovedArtifact {
data class BuildId(val value: String) : ApprovedArtifact
data class ApkSha256(val value: String) : ApprovedArtifact
}
enum class BlockAction { Block, Purge }
data class TrustBlock(val match: BlockMatch, val action: BlockAction, val reason: String)
sealed interface BlockMatch {
data class SignerSha256(val value: String) : BlockMatch
data class ToolId(val value: String) : BlockMatch
data class ToolVersion(val toolId: String, val versionCode: Long) : BlockMatch
data class ApkSha256(val value: String) : BlockMatch
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.thelightphone.sdk.trust

object LightTrustBundleFormat {
const val SUPPORTED_SCHEMA_VERSION = 1

val signedPayloadPrefix: ByteArray get() = prefix.copyOf()

private val prefix: ByteArray by lazy {
val separator = checkNotNull(javaClass.getResourceAsStream("/bundle-domain-separator.txt")) {
"missing trust bundle domain separator"
}.bufferedReader().use { it.readText().trimEnd('\n', '\r') }
separator.encodeToByteArray() + byteArrayOf(0)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.thelightphone.sdk.trust

import org.bouncycastle.asn1.ASN1Encoding
import org.bouncycastle.asn1.ASN1ObjectIdentifier
import org.bouncycastle.asn1.ASN1Primitive
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.io.IOException

// Pinned public keys are encoded as DER SubjectPublicKeyInfo
class LightTrustBundleVerifier(pinnedKeys: List<ByteArray>) {
private val pins = pinnedKeys.map { it.copyOf() }

fun verify(bytes: ByteArray, signature: ByteArray): TrustResult<LightTrustBundle> {
val payload = bytes.copyOf()
val detached = signature.copyOf()
if (pins.isEmpty()) {
return TrustResult.Failure(TrustFailure.InvalidKey)
}

val keys = try {
pins.map {
parsePin(it) ?: return TrustResult.Failure(TrustFailure.InvalidKey)
}
} catch (_: IOException) {
return TrustResult.Failure(TrustFailure.InvalidKey)
} catch (_: IllegalArgumentException) {
return TrustResult.Failure(TrustFailure.InvalidKey)
}

if (detached.size != 64) return TrustResult.Failure(TrustFailure.InvalidSignature)
val prefix = LightTrustBundleFormat.signedPayloadPrefix
val verified = keys.any { key ->
val verifier = Ed25519Signer()
verifier.init(false, key)
verifier.update(prefix, 0, prefix.size)
verifier.update(payload, 0, payload.size)
verifier.verifySignature(detached)
}
if (!verified) return TrustResult.Failure(TrustFailure.InvalidSignature)
return LightTrustBundleParser.parse(payload)
}

private fun parsePin(bytes: ByteArray): Ed25519PublicKeyParameters? {
val info = SubjectPublicKeyInfo.getInstance(ASN1Primitive.fromByteArray(bytes)) ?: return null
if (info.algorithm.algorithm != ED25519_OID || info.algorithm.parameters != null ||
!info.getEncoded(ASN1Encoding.DER).contentEquals(bytes) ||
info.publicKeyData.padBits != 0
) {
return null
}

val raw = info.publicKeyData.octets
if (raw.size != Ed25519PublicKeyParameters.KEY_SIZE) return null
return Ed25519PublicKeyParameters(raw)
}

companion object {
private val ED25519_OID = ASN1ObjectIdentifier("1.3.101.112")
}
}
Loading