-
Notifications
You must be signed in to change notification settings - Fork 114
feat: add signed trust bundles, JVM verification and install policy #207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+2,627
−4
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
a66a24e
feat: add initial trust bundle builder and vectors
brunoro 9ac7a14
fix: harden trust bundle validation and build recovery
brunoro 0054f57
fix: validate all bundle pins and normalize separator bytes
brunoro 7c8952a
feat: add JVM trust bundle verification and store
brunoro 4975973
fix: parse certs using BouncyCastle
brunoro 7f906bf
test: clarify trust bundle test scenarios
brunoro db15c1f
fix: enforce trust bundle encoding parity
brunoro ad81e8a
feat: add LightInstallPolicy and the threat matrix
brunoro 699a90f
ci: stop installing the removed `tools` SDK package
brunoro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| trust-format/** text eol=lf |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
196 changes: 196 additions & 0 deletions
196
sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightInstallPolicy.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
| } | ||
| } | ||
| } | ||
25 changes: 25 additions & 0 deletions
25
sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustBundle.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
14 changes: 14 additions & 0 deletions
14
sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustBundleFormat.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
62 changes: 62 additions & 0 deletions
62
sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustBundleVerifier.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
detektfor stuff like this. Considering moving some of the build plugin to use that as well.