Skip to content
Closed
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,7 @@ bin/
### Python (builder/) ###
__pycache__/
*.py[cod]
.pytest_cache/
.pytest_cache/

### Signing keys ###
/signer/keys/
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ group = "com.thelightphone"

ext["compileSdk"] = 36
ext["minSdk"] = 34
ext["targetSdk"] = 36
ext["targetSdk"] = 34
ext["jvmTarget"] = "17"
ext["lintVersion"] = "31.12.3"

Expand Down
3 changes: 1 addition & 2 deletions builder/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# Example:
# docker build \
# --build-arg SDK_GIT_URL=https://github.com/lightphone/light-sdk \
# --build-arg SDK_GIT_REF=<commit-sha> \
# --build-arg SDK_GIT_REF=<tag-or-commit> \
# -t lightphone/light-builder:<tag> builder/
#
# Runtime:
Expand Down Expand Up @@ -79,7 +79,6 @@ FROM --platform=linux/amd64 base AS sdk
ARG SDK_GIT_URL=https://github.com/lightphone/light-sdk
ARG SDK_GIT_REF

# SDK_GIT_REF must be a commit-ish; fail loud if the caller forgot it.
RUN test -n "${SDK_GIT_REF}" || (echo "SDK_GIT_REF build arg required" >&2; exit 1)

RUN git clone --no-tags "${SDK_GIT_URL}" /opt/light-sdk; \
Expand Down
23 changes: 21 additions & 2 deletions builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ flag and AGP signs with the shared dev keystore as usual.
DOCKER_BUILDKIT=1 docker build \
-f builder/Dockerfile \
--build-arg SDK_GIT_URL=https://github.com/lightphone/light-sdk \
--build-arg SDK_GIT_REF=<commit-sha-or-tag> \
--build-arg SDK_GIT_REF=<tag-or-commit> \
-t lightphone/light-builder:<tag> \
builder/
```
Expand Down Expand Up @@ -149,7 +149,7 @@ Inside `--output-dir`:
| File | Purpose |
|------------------|------------------------------------------------------------------------|
| `tool-unsigned.apk` | The build artifact. |
| `recipe.json` | SHA-256 + every input that fed the build. The signing job must verify the dev-commit hash against this before signing. |
| `recipe.json` | SHA-256 + every input that fed the build. The signing job must verify the tool commit against this before signing. |
| `extraction.json`| List of files the extractor accepted from the dev's repo. |
| `extracted-source.zip` | The accepted source files themselves, zipped exactly as staged into the tool module (`build.gradle.kts`, `lighttool.toml`, `src/main/**`). Deterministic archive — same commit produces a byte-identical zip. |
| `build.log` | Gradle stdout/stderr, plus the extractor's log. |
Expand All @@ -159,6 +159,24 @@ Inside `--output-dir`:
`sha256` into the signing queue alongside the build ID, and have the signer
refuse to sign if the artifact's hash doesn't match.

Its `tool` object and `sdkGitRef` are copied unchanged into the trust statement:

```json
{
"tool": {
"id": "com.example.mytool",
"versionCode": 1,
"versionName": "1.0.0",
"gitUrl": "https://github.com/example/mytool",
"gitCommit": "<full commit SHA>"
},
"sdkGitRef": "v0.1.1"
}
```

Artifact metadata and builder-specific inputs remain in the recipe's `artifact`
and `build` objects.

## `lighttool.toml` schema

```toml
Expand Down Expand Up @@ -188,6 +206,7 @@ commit.
```sh
# Python (extraction policy)
cd builder
# Python 3.11 or newer is required (`tomllib` is part of the standard library).
python3 -m venv .venv
.venv/bin/pip install pytest
.venv/bin/python -m pytest tests/
Expand Down
6 changes: 3 additions & 3 deletions builder/bin/build-apk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ python3 -m lightbuilder collect \
--output-dir "$OUTPUT_DIR" \
--image-digest "$LIGHT_IMAGE_DIGEST" \
--sdk-git-ref "$LIGHT_SDK_GIT_REF" \
--dev-git-url "$GIT_URL" \
--dev-git-ref "$GIT_REF" \
--dev-git-commit "$DEV_GIT_COMMIT" \
--tool-git-url "$GIT_URL" \
--tool-git-ref "$GIT_REF" \
--tool-git-commit "$DEV_GIT_COMMIT" \
--gradle-command "$GRADLE_CMD_JSON" \
--source-date-epoch "$DEV_COMMIT_EPOCH"

Expand Down
26 changes: 18 additions & 8 deletions builder/lightbuilder/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import zipfile
from pathlib import Path

import tomllib

from . import extract, recipe


Expand Down Expand Up @@ -94,14 +96,22 @@ def cmd_collect(args: argparse.Namespace) -> int:
if report_path.exists():
extracted_files = tuple(json.loads(report_path.read_text())["files"])

tool_config = tomllib.loads(
(args.workspace / "tool" / "lighttool.toml").read_text(encoding="utf-8")
)["tool"]
result = recipe.write(
artifact=out_apk,
inputs=recipe.BuildInputs(
tool=recipe.Tool(
id=tool_config["id"],
version_code=tool_config["versionCode"],
version_name=tool_config["versionName"],
git_url=args.tool_git_url,
git_commit=args.tool_git_commit,
),
sdk_git_ref=args.sdk_git_ref,
build=recipe.Build(
image_digest=args.image_digest,
sdk_git_ref=args.sdk_git_ref,
dev_git_url=args.dev_git_url,
dev_git_ref=args.dev_git_ref,
dev_git_commit=args.dev_git_commit,
tool_git_ref=args.tool_git_ref,
gradle_command=tuple(json.loads(args.gradle_command)),
source_date_epoch=args.source_date_epoch,
extracted_files=extracted_files,
Expand Down Expand Up @@ -164,9 +174,9 @@ def _parse(argv: list[str] | None) -> argparse.Namespace:
coll.add_argument("--output-dir", type=Path, required=True)
coll.add_argument("--image-digest", required=True)
coll.add_argument("--sdk-git-ref", required=True)
coll.add_argument("--dev-git-url", required=True)
coll.add_argument("--dev-git-ref", required=True)
coll.add_argument("--dev-git-commit", required=True)
coll.add_argument("--tool-git-url", required=True)
coll.add_argument("--tool-git-ref", required=True)
coll.add_argument("--tool-git-commit", required=True)
coll.add_argument("--gradle-command", required=True, help="JSON-encoded argv array")
coll.add_argument("--source-date-epoch", type=int, required=True)

Expand Down
38 changes: 30 additions & 8 deletions builder/lightbuilder/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@

import hashlib
import json
from dataclasses import asdict, dataclass, field
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


@dataclass(frozen=True)
class BuildInputs:
class Tool:
id: str
version_code: int
version_name: str
git_url: str
git_commit: str


@dataclass(frozen=True)
class Build:
image_digest: str
sdk_git_ref: str
dev_git_url: str
dev_git_ref: str
dev_git_commit: str
tool_git_ref: str
gradle_command: tuple[str, ...]
source_date_epoch: int
extracted_files: tuple[str, ...] = field(default_factory=tuple)
Expand All @@ -36,7 +42,9 @@ def sha256(path: Path) -> str:
def write(
*,
artifact: Path,
inputs: BuildInputs,
tool: Tool,
sdk_git_ref: str,
build: Build,
dest: Path,
) -> dict[str, Any]:
artifact_hash = sha256(artifact)
Expand All @@ -47,7 +55,21 @@ def write(
"sizeBytes": artifact.stat().st_size,
"sha256": artifact_hash,
},
"inputs": asdict(inputs),
"tool": {
"id": tool.id,
"versionCode": tool.version_code,
"versionName": tool.version_name,
"gitUrl": tool.git_url,
"gitCommit": tool.git_commit,
},
"sdkGitRef": sdk_git_ref,
"build": {
"imageDigest": build.image_digest,
"toolGitRef": build.tool_git_ref,
"gradleCommand": list(build.gradle_command),
"sourceDateEpoch": build.source_date_epoch,
"extractedFiles": list(build.extracted_files),
},
}
# sort_keys for deterministic JSON output — the recipe itself should be
# byte-stable when the inputs are.
Expand Down
2 changes: 0 additions & 2 deletions builder/tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@
from pathlib import Path

import pytest

from lightbuilder.extract import ExtractionError, extract


VALID_BUILD_GRADLE = """\
plugins {
alias(libs.plugins.android.application)
Expand Down
43 changes: 43 additions & 0 deletions builder/tests/test_recipe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

import json
from pathlib import Path

from lightbuilder import recipe


def test_recipe_uses_shared_tool_and_sdk_objects(tmp_path: Path) -> None:
artifact = tmp_path / "tool-unsigned.apk"
artifact.write_bytes(b"apk")
destination = tmp_path / "recipe.json"

record = recipe.write(
artifact=artifact,
tool=recipe.Tool(
id="com.example.tool",
version_code=3,
version_name="1.2.0",
git_url="https://github.com/example/tool",
git_commit="a" * 40,
),
sdk_git_ref="v0.1.1",
build=recipe.Build(
image_digest="sha256:image",
tool_git_ref="v1.2.0",
gradle_command=("./gradlew", ":tool:assembleRelease"),
source_date_epoch=1_787_616_000,
extracted_files=("lighttool.toml",),
),
dest=destination,
)

assert record["tool"] == {
"id": "com.example.tool",
"versionCode": 3,
"versionName": "1.2.0",
"gitUrl": "https://github.com/example/tool",
"gitCommit": "a" * 40,
}
assert record["sdkGitRef"] == "v0.1.1"
assert record["build"]["toolGitRef"] == "v1.2.0"
assert json.loads(destination.read_text(encoding="utf-8")) == record
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ Topics:
- [Navigating this repository](repo)
- [Overview of Light primitives (tool building blocks)](../sdk/client)
- [Declaring tool metadata (name, version, etc.)](tool_metadata)
- [Tool signing and trust statements](tool_signing)
- [Using the LightOS Emulator](system_app)
- [Why parts of the SDK are built the way they are](design_decisions)
52 changes: 52 additions & 0 deletions docs/tool_signing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Tool signing and trust statements

Tools are built and signed using Light CI infrastructure, and then verified in the phone by LightOS.
Signing lets LightOS confirm that an APK came through Light's build pipeline and was not modified afterward.
Android also uses the APK signing key as the app's identity, allowing updates only when they are signed by the same per-tool key.

## Building tools

The builder runs developer source in an isolated environment with no signing keys. It produces:
- `tool-unsigned.apk`: the unsigned Android package.
- `recipe.json`: a record of the artifact, tool source, SDK git ref, and build inputs.

The `tool` object and `sdkGitRef` in the recipe are later copied into the trust statement.
Builder code lives in `builder/`.

## Signing APKs

Signing happens separately from building. The signer will:
1. verify the build recipe and unsigned APK
2. add the trust statement file to the APK at `META-INF/light-trust.json`
3. sign the APK with its per-tool Android signing key.

The trust statement identifies the tool, SDK, developer, build, unsigned APK, and APK signing certificate.
Light attests the statement by signing it with a separate Light attestation key (ed25519 algorithm).

## Verifying signed APKs

The device reads the statement and produces the same canonical JSON bytes used
by the signer. It verifies the Ed25519 signature against a pinned Light public
key and compares `signerSha256` with the APK certificate reported by Android.

The pure-JVM verification foundation lives in `sdk/trust/`:

- `LightTrustStatement` defines the statement fields.
- `LightTrustCanonicalizer` produces deterministic signature bytes.
- `LightAttestationVerifier` verifies Ed25519 signatures.

## Canonicalization

Canonical JSON is the PoC format. A later task will evaluate replacing it with a
standard signing envelope such as DSSE, which carries the exact signed payload
bytes and removes the need for Python and Kotlin to reserialize JSON identically.

Signing is implemented in Python while verification is implemented in Kotlin.
Their JSON libraries may differ in key ordering, escaping, and number handling,
so we need a canonicalization step to generate a stable attestation byte sequence
that can be used to verify the signature.

For the same reason, tests in either language alone could pass even when the two
implementations are incompatible. Both use the fixtures in
`signer/tests/vectors/`, ensuring they agree byte-for-byte and verify the same
signature.
24 changes: 24 additions & 0 deletions sdk/trust/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}

java {
sourceCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String)
targetCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String)
}

kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(rootProject.ext["jvmTarget"] as String))
}
}

dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlin.test)
}

tasks.test {
useJUnitPlatform()
systemProperty("lightTrustVectors", rootProject.file("signer/tests/vectors").absolutePath)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.thelightphone.sdk.trust

import java.security.KeyFactory
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64

object LightAttestationVerifier {
fun verify(payload: ByteArray, signature: ByteArray, publicKeyPem: String): Boolean {
val encodedKey = publicKeyPem
.lineSequence()
.filterNot { it.startsWith("-----") }
.joinToString("")
.let(Base64.getDecoder()::decode)
val publicKey = KeyFactory.getInstance("Ed25519")
.generatePublic(X509EncodedKeySpec(encodedKey))
return Signature.getInstance("Ed25519").run {
initVerify(publicKey)
update(payload)
verify(signature)
}
}
}
Loading
Loading