From 45713d5a5ed063133fa0699f779988644c2b28b4 Mon Sep 17 00:00:00 2001 From: Gustavo Brunoro Date: Tue, 25 Aug 2026 21:38:36 -0300 Subject: [PATCH 1/3] feat: add tool trust statement foundation --- .gitignore | 5 +- builder/Dockerfile | 3 +- builder/README.md | 22 ++++- builder/bin/build-apk.sh | 5 +- builder/lightbuilder/__main__.py | 23 +++-- builder/lightbuilder/recipe.py | 36 ++++++-- builder/tests/test_recipe.py | 41 +++++++++ docs/README.md | 1 + docs/tool_signing/README.md | 52 ++++++++++++ sdk/trust/build.gradle.kts | 24 ++++++ .../sdk/trust/LightAttestationVerifier.kt | 23 +++++ .../sdk/trust/LightTrustCanonicalizer.kt | 72 ++++++++++++++++ .../sdk/trust/LightTrustStatement.kt | 27 ++++++ .../sdk/trust/LightTrustVectorTest.kt | 61 +++++++++++++ settings.gradle.kts | 1 + signer/lightsigner/__init__.py | 1 + signer/lightsigner/statement.py | 85 +++++++++++++++++++ signer/tests/__init__.py | 1 + signer/tests/conftest.py | 9 ++ signer/tests/test_statement.py | 58 +++++++++++++ signer/tests/vectors/README.md | 11 +++ signer/tests/vectors/__init__.py | 1 + signer/tests/vectors/generate.py | 41 +++++++++ signer/tests/vectors/statement.canonical.json | 1 + signer/tests/vectors/statement.json | 21 +++++ .../vectors/test-attestation-private.pem | 3 + .../tests/vectors/test-attestation-public.pem | 3 + 27 files changed, 607 insertions(+), 24 deletions(-) create mode 100644 builder/tests/test_recipe.py create mode 100644 docs/tool_signing/README.md create mode 100644 sdk/trust/build.gradle.kts create mode 100644 sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightAttestationVerifier.kt create mode 100644 sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustCanonicalizer.kt create mode 100644 sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustStatement.kt create mode 100644 sdk/trust/src/test/kotlin/com/thelightphone/sdk/trust/LightTrustVectorTest.kt create mode 100644 signer/lightsigner/__init__.py create mode 100644 signer/lightsigner/statement.py create mode 100644 signer/tests/__init__.py create mode 100644 signer/tests/conftest.py create mode 100644 signer/tests/test_statement.py create mode 100644 signer/tests/vectors/README.md create mode 100644 signer/tests/vectors/__init__.py create mode 100644 signer/tests/vectors/generate.py create mode 100644 signer/tests/vectors/statement.canonical.json create mode 100644 signer/tests/vectors/statement.json create mode 100644 signer/tests/vectors/test-attestation-private.pem create mode 100644 signer/tests/vectors/test-attestation-public.pem diff --git a/.gitignore b/.gitignore index 3034b8d03..a69e24fba 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ bin/ ### Python (builder/) ### __pycache__/ *.py[cod] -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +### Signing keys ### +/signer/keys/ diff --git a/builder/Dockerfile b/builder/Dockerfile index 36dad646d..9585b50f3 100644 --- a/builder/Dockerfile +++ b/builder/Dockerfile @@ -15,7 +15,7 @@ # Example: # docker build \ # --build-arg SDK_GIT_URL=https://github.com/lightphone/light-sdk \ -# --build-arg SDK_GIT_REF= \ +# --build-arg SDK_GIT_REF= \ # -t lightphone/light-builder: builder/ # # Runtime: @@ -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; \ diff --git a/builder/README.md b/builder/README.md index bc7885792..64e86854c 100644 --- a/builder/README.md +++ b/builder/README.md @@ -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= \ + --build-arg SDK_GIT_REF= \ -t lightphone/light-builder: \ builder/ ``` @@ -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. | @@ -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": "" + }, + "sdkGitRef": "v0.1.1" +} +``` + +Artifact metadata and builder-specific inputs remain in the recipe's `artifact` +and `build` objects. + ## `lighttool.toml` schema ```toml diff --git a/builder/bin/build-apk.sh b/builder/bin/build-apk.sh index 7d37c94d0..4fd91cdd9 100755 --- a/builder/bin/build-apk.sh +++ b/builder/bin/build-apk.sh @@ -146,9 +146,8 @@ 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-commit "$DEV_GIT_COMMIT" \ --gradle-command "$GRADLE_CMD_JSON" \ --source-date-epoch "$DEV_COMMIT_EPOCH" diff --git a/builder/lightbuilder/__main__.py b/builder/lightbuilder/__main__.py index c02c2e9e4..ee84725ad 100644 --- a/builder/lightbuilder/__main__.py +++ b/builder/lightbuilder/__main__.py @@ -23,6 +23,7 @@ import json import shutil import sys +import tomllib import zipfile from pathlib import Path @@ -94,14 +95,21 @@ 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, gradle_command=tuple(json.loads(args.gradle_command)), source_date_epoch=args.source_date_epoch, extracted_files=extracted_files, @@ -164,9 +172,8 @@ 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-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) diff --git a/builder/lightbuilder/recipe.py b/builder/lightbuilder/recipe.py index 9c18c7710..153adac67 100644 --- a/builder/lightbuilder/recipe.py +++ b/builder/lightbuilder/recipe.py @@ -8,18 +8,23 @@ 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 gradle_command: tuple[str, ...] source_date_epoch: int extracted_files: tuple[str, ...] = field(default_factory=tuple) @@ -36,7 +41,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) @@ -47,7 +54,20 @@ 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, + "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. diff --git a/builder/tests/test_recipe.py b/builder/tests/test_recipe.py new file mode 100644 index 000000000..3fb03edb5 --- /dev/null +++ b/builder/tests/test_recipe.py @@ -0,0 +1,41 @@ +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", + 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 json.loads(destination.read_text(encoding="utf-8")) == record diff --git a/docs/README.md b/docs/README.md index d4a3496d6..d713fb81c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/tool_signing/README.md b/docs/tool_signing/README.md new file mode 100644 index 000000000..a6ea47626 --- /dev/null +++ b/docs/tool_signing/README.md @@ -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. diff --git a/sdk/trust/build.gradle.kts b/sdk/trust/build.gradle.kts new file mode 100644 index 000000000..b3a06853e --- /dev/null +++ b/sdk/trust/build.gradle.kts @@ -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) +} diff --git a/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightAttestationVerifier.kt b/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightAttestationVerifier.kt new file mode 100644 index 000000000..319513e29 --- /dev/null +++ b/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightAttestationVerifier.kt @@ -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) + } + } +} diff --git a/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustCanonicalizer.kt b/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustCanonicalizer.kt new file mode 100644 index 000000000..25eb21090 --- /dev/null +++ b/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustCanonicalizer.kt @@ -0,0 +1,72 @@ +package com.thelightphone.sdk.trust + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +object LightTrustCanonicalizer { + private val integerPattern = Regex("-?(0|[1-9][0-9]*)") + private val keyComparator = Comparator { left, right -> compareCodePoints(left, right) } + + fun statementBytes(document: ByteArray): ByteArray { + val root = Json.parseToJsonElement(document.decodeToString()) as? JsonObject + ?: throw IllegalArgumentException("trust statement must be a JSON object") + return canonicalObject(JsonObject(root - "attestation")).encodeToByteArray() + } + + private fun canonical(element: JsonElement): String = when (element) { + is JsonObject -> canonicalObject(element) + is JsonArray -> element.joinToString(prefix = "[", postfix = "]", separator = ",", transform = ::canonical) + JsonNull -> "null" + is JsonPrimitive -> canonicalPrimitive(element) + } + + private fun canonicalObject(value: JsonObject): String = value.entries + .sortedWith { left, right -> keyComparator.compare(left.key, right.key) } + .joinToString(prefix = "{", postfix = "}", separator = ",") { (key, child) -> + "${quote(key)}:${canonical(child)}" + } + + private fun canonicalPrimitive(value: JsonPrimitive): String { + if (value.isString) return quote(value.content) + require(value.content in setOf("true", "false") || integerPattern.matches(value.content)) { + "floats are not allowed in trust documents" + } + return value.content + } + + private fun quote(value: String): String = buildString { + append('"') + value.forEach { character -> + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (character.code < 0x20) { + append("\\u") + append(character.code.toString(16).padStart(4, '0')) + } else { + append(character) + } + } + } + append('"') + } + + private fun compareCodePoints(left: String, right: String): Int { + val leftPoints = left.codePoints().iterator() + val rightPoints = right.codePoints().iterator() + while (leftPoints.hasNext() && rightPoints.hasNext()) { + val comparison = leftPoints.nextInt().compareTo(rightPoints.nextInt()) + if (comparison != 0) return comparison + } + return leftPoints.hasNext().compareTo(rightPoints.hasNext()) + } +} diff --git a/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustStatement.kt b/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustStatement.kt new file mode 100644 index 000000000..f14667a9e --- /dev/null +++ b/sdk/trust/src/main/kotlin/com/thelightphone/sdk/trust/LightTrustStatement.kt @@ -0,0 +1,27 @@ +package com.thelightphone.sdk.trust + +data class LightTrustTool( + val id: String, + val versionCode: Long, + val versionName: String, + val gitUrl: String, + val gitCommit: String, +) + +data class LightAttestation( + val keyId: String, + val alg: String, + val sig: String, +) + +data class LightTrustStatement( + val schemaVersion: Int, + val tool: LightTrustTool, + val sdkGitRef: String, + val devId: String, + val signerSha256: String, + val buildId: String, + val unsignedSha256: String, + val issuedAt: String, + val attestation: LightAttestation, +) diff --git a/sdk/trust/src/test/kotlin/com/thelightphone/sdk/trust/LightTrustVectorTest.kt b/sdk/trust/src/test/kotlin/com/thelightphone/sdk/trust/LightTrustVectorTest.kt new file mode 100644 index 000000000..23cda33e1 --- /dev/null +++ b/sdk/trust/src/test/kotlin/com/thelightphone/sdk/trust/LightTrustVectorTest.kt @@ -0,0 +1,61 @@ +package com.thelightphone.sdk.trust + +import java.nio.file.Path +import java.util.Base64 +import kotlin.io.path.readBytes +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class LightTrustVectorTest { + private val vectors = Path.of(System.getProperty("lightTrustVectors")) + private val statement = vectors.resolve("statement.json").readBytes() + + @Test + fun `canonical bytes match Python vector`() { + assertContentEquals( + vectors.resolve("statement.canonical.json").readBytes(), + LightTrustCanonicalizer.statementBytes(statement), + ) + } + + @Test + fun `unicode remains UTF-8`() { + val canonical = LightTrustCanonicalizer.statementBytes(statement).decodeToString() + assertTrue("luz-☀" in canonical) + assertFalse("\\u2600" in canonical) + } + + @Test + fun `reordered keys canonicalize identically`() { + val root = Json.parseToJsonElement(statement.decodeToString()).jsonObject + val reordered = JsonObject(root.entries.reversed().associate { it.toPair() }) + .toString() + .encodeToByteArray() + assertContentEquals( + LightTrustCanonicalizer.statementBytes(statement), + LightTrustCanonicalizer.statementBytes(reordered), + ) + } + + @Test + fun `vector signature verifies`() { + val document = Json.parseToJsonElement(statement.decodeToString()).jsonObject + val signature = Base64.getDecoder().decode( + document.getValue("attestation").jsonObject.getValue("sig").jsonPrimitive.content + ) + assertTrue( + LightAttestationVerifier.verify( + LightTrustCanonicalizer.statementBytes(statement), + signature, + vectors.resolve("test-attestation-public.pem").readText(), + ) + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9bf808c8a..c1e6e6a39 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,7 @@ rootProject.name = "light-sdk" includeBuild("plugin") include(":lint-rules") include(":sdk:shared") +include(":sdk:trust") include(":sdk:ui") include(":sdk:client") include(":sdk:server") diff --git a/signer/lightsigner/__init__.py b/signer/lightsigner/__init__.py new file mode 100644 index 000000000..1d5f75123 --- /dev/null +++ b/signer/lightsigner/__init__.py @@ -0,0 +1 @@ +"""Light signing-zone utilities.""" diff --git a/signer/lightsigner/statement.py b/signer/lightsigner/statement.py new file mode 100644 index 000000000..334e4bb0f --- /dev/null +++ b/signer/lightsigner/statement.py @@ -0,0 +1,85 @@ +"""Canonical Light trust statement bytes and Ed25519 operations.""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +def canonical_bytes(document: Mapping[str, Any]) -> bytes: + payload = dict(document) + payload.pop("attestation", None) + _reject_floats(payload) + return json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def _reject_floats(value: Any) -> None: + if isinstance(value, float): + raise ValueError("floats are not allowed in trust documents") + if isinstance(value, Mapping): + for child in value.values(): + _reject_floats(child) + elif isinstance(value, (list, tuple)): + for child in value: + _reject_floats(child) + + +def sign_ed25519(payload: bytes, private_key: Path) -> bytes: + with tempfile.NamedTemporaryFile() as payload_file: + payload_file.write(payload) + payload_file.flush() + result = subprocess.run( + [ + "openssl", + "pkeyutl", + "-sign", + "-rawin", + "-inkey", + str(private_key), + "-in", + payload_file.name, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + raise ValueError(result.stderr.decode("utf-8", errors="replace").strip()) + return result.stdout + + +def verify_ed25519(payload: bytes, signature: bytes, public_key: Path) -> bool: + with tempfile.NamedTemporaryFile() as payload_file, tempfile.NamedTemporaryFile() as signature_file: + payload_file.write(payload) + payload_file.flush() + signature_file.write(signature) + signature_file.flush() + result = subprocess.run( + [ + "openssl", + "pkeyutl", + "-verify", + "-rawin", + "-pubin", + "-inkey", + str(public_key), + "-in", + payload_file.name, + "-sigfile", + signature_file.name, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + return result.returncode == 0 diff --git a/signer/tests/__init__.py b/signer/tests/__init__.py new file mode 100644 index 000000000..74f1fbed1 --- /dev/null +++ b/signer/tests/__init__.py @@ -0,0 +1 @@ +"""Signer tests and shared vectors.""" diff --git a/signer/tests/conftest.py b/signer/tests/conftest.py new file mode 100644 index 000000000..6a2c99839 --- /dev/null +++ b/signer/tests/conftest.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +SIGNER_ROOT = Path(__file__).resolve().parent.parent +if str(SIGNER_ROOT) not in sys.path: + sys.path.insert(0, str(SIGNER_ROOT)) diff --git a/signer/tests/test_statement.py b/signer/tests/test_statement.py new file mode 100644 index 000000000..d9c3c7085 --- /dev/null +++ b/signer/tests/test_statement.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +from lightsigner.statement import canonical_bytes, verify_ed25519 + + +VECTORS = Path(__file__).parent / "vectors" + + +def _statement() -> dict[str, object]: + return json.loads((VECTORS / "statement.json").read_text(encoding="utf-8")) + + +def test_canonical_bytes_match_vector() -> None: + assert canonical_bytes(_statement()) == (VECTORS / "statement.canonical.json").read_bytes() + + +def test_reordered_keys_canonicalize_identically() -> None: + statement = _statement() + reordered = dict(reversed(list(statement.items()))) + assert canonical_bytes(reordered) == canonical_bytes(statement) + + +def test_unicode_is_utf8_not_ascii_escaped() -> None: + canonical = canonical_bytes(_statement()) + assert "luz-☀".encode() in canonical + assert b"\\u2600" not in canonical + + +def test_vector_signature_verifies() -> None: + statement = _statement() + signature = base64.b64decode(statement["attestation"]["sig"], validate=True) + assert verify_ed25519( + canonical_bytes(statement), signature, VECTORS / "test-attestation-public.pem" + ) + + +def test_modified_statement_does_not_verify() -> None: + statement = _statement() + signature = base64.b64decode(statement["attestation"]["sig"], validate=True) + statement["tool"]["versionCode"] = 4 + assert not verify_ed25519( + canonical_bytes(statement), signature, VECTORS / "test-attestation-public.pem" + ) + + +def test_floats_are_rejected() -> None: + statement = _statement() + statement["schemaVersion"] = 1.0 + try: + canonical_bytes(statement) + except ValueError as error: + assert str(error) == "floats are not allowed in trust documents" + else: + raise AssertionError("float was accepted") diff --git a/signer/tests/vectors/README.md b/signer/tests/vectors/README.md new file mode 100644 index 000000000..c53c73120 --- /dev/null +++ b/signer/tests/vectors/README.md @@ -0,0 +1,11 @@ +# Test-only attestation key + +The private key in this directory exists only to regenerate deterministic test +vectors. It is public, provides no trust, and must never be configured as a +production Light attestation key. + +Regenerate from `signer/`: + +```sh +python3 -m tests.vectors.generate +``` diff --git a/signer/tests/vectors/__init__.py b/signer/tests/vectors/__init__.py new file mode 100644 index 000000000..989d0b1a3 --- /dev/null +++ b/signer/tests/vectors/__init__.py @@ -0,0 +1 @@ +"""Cross-language trust statement vectors.""" diff --git a/signer/tests/vectors/generate.py b/signer/tests/vectors/generate.py new file mode 100644 index 000000000..e5e2e68da --- /dev/null +++ b/signer/tests/vectors/generate.py @@ -0,0 +1,41 @@ +"""Regenerate test-only trust statement vectors.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +from lightsigner.statement import canonical_bytes, sign_ed25519 + +VECTORS = Path(__file__).resolve().parent + + +statement: dict[str, object] = { + "schemaVersion": 1, + "tool": { + "id": "com.example.tool", + "versionCode": 3, + "versionName": "1.2.0", + "gitUrl": "https://github.com/example/luz-☀", + "gitCommit": "a" * 40, + }, + "sdkGitRef": "v0.1.1", + "devId": "dev_test_01", + "signerSha256": "c" * 64, + "buildId": "build_test_01", + "unsignedSha256": "d" * 64, + "issuedAt": "2026-08-25T00:00:00Z", +} +canonical = canonical_bytes(statement) +signature = sign_ed25519(canonical, VECTORS / "test-attestation-private.pem") +statement["attestation"] = { + "keyId": "test-attestation-1", + "alg": "Ed25519", + "sig": base64.b64encode(signature).decode("ascii"), +} + +(VECTORS / "statement.canonical.json").write_bytes(canonical) +(VECTORS / "statement.json").write_text( + json.dumps(statement, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" +) diff --git a/signer/tests/vectors/statement.canonical.json b/signer/tests/vectors/statement.canonical.json new file mode 100644 index 000000000..2191c14ed --- /dev/null +++ b/signer/tests/vectors/statement.canonical.json @@ -0,0 +1 @@ +{"buildId":"build_test_01","devId":"dev_test_01","issuedAt":"2026-08-25T00:00:00Z","schemaVersion":1,"sdkGitRef":"v0.1.1","signerSha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","tool":{"gitCommit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","gitUrl":"https://github.com/example/luz-☀","id":"com.example.tool","versionCode":3,"versionName":"1.2.0"},"unsignedSha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} \ No newline at end of file diff --git a/signer/tests/vectors/statement.json b/signer/tests/vectors/statement.json new file mode 100644 index 000000000..0f517b8b4 --- /dev/null +++ b/signer/tests/vectors/statement.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "tool": { + "id": "com.example.tool", + "versionCode": 3, + "versionName": "1.2.0", + "gitUrl": "https://github.com/example/luz-☀", + "gitCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "sdkGitRef": "v0.1.1", + "devId": "dev_test_01", + "signerSha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "buildId": "build_test_01", + "unsignedSha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "issuedAt": "2026-08-25T00:00:00Z", + "attestation": { + "keyId": "test-attestation-1", + "alg": "Ed25519", + "sig": "W05u8BVCmTPn3zL9QHN0ApQHQML2C851h5AOjzx1Ziehj1vdowlBucfAKOBYdZwNFG9YAAH6/aGWNnTQMde1Aw==" + } +} diff --git a/signer/tests/vectors/test-attestation-private.pem b/signer/tests/vectors/test-attestation-private.pem new file mode 100644 index 000000000..82f97034f --- /dev/null +++ b/signer/tests/vectors/test-attestation-private.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIKrW7w+SoDTmumrY8b9OBbFKow3BNSk3I37xp+ge3z1/ +-----END PRIVATE KEY----- diff --git a/signer/tests/vectors/test-attestation-public.pem b/signer/tests/vectors/test-attestation-public.pem new file mode 100644 index 000000000..faefcd3f0 --- /dev/null +++ b/signer/tests/vectors/test-attestation-public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEArLPkdPhKzBPqTVNUJsQ/MSe6L4NtD84KmtizrrU2kCI= +-----END PUBLIC KEY----- From b0eb07525d2a37771cac09aac39909500976a01e Mon Sep 17 00:00:00 2001 From: Gustavo Brunoro Date: Wed, 26 Aug 2026 17:50:47 -0300 Subject: [PATCH 2/3] feat: add tool signing CLI --- build.gradle.kts | 2 +- builder/README.md | 1 + builder/bin/build-apk.sh | 1 + builder/lightbuilder/__main__.py | 2 + builder/lightbuilder/recipe.py | 2 + builder/tests/test_recipe.py | 2 + signer/README.md | 47 ++++++++++ signer/lightsigner/__main__.py | 84 ++++++++++++++++++ signer/lightsigner/apk.py | 34 +++++++ signer/lightsigner/errors.py | 7 ++ signer/lightsigner/keys.py | 95 ++++++++++++++++++++ signer/lightsigner/recipe.py | 54 ++++++++++++ signer/lightsigner/registry.py | 73 ++++++++++++++++ signer/lightsigner/signing.py | 47 ++++++++++ signer/lightsigner/stamp.py | 107 +++++++++++++++++++++++ signer/lightsigner/tools.py | 63 +++++++++++++ signer/lightsigner/verify.py | 52 +++++++++++ signer/registry.json | 1 + signer/tests/test_android_integration.py | 92 +++++++++++++++++++ signer/tests/test_registry.py | 27 ++++++ signer/tests/test_stamp.py | 59 +++++++++++++ signer/tests/test_verify.py | 60 +++++++++++++ 22 files changed, 911 insertions(+), 1 deletion(-) create mode 100644 signer/README.md create mode 100644 signer/lightsigner/__main__.py create mode 100644 signer/lightsigner/apk.py create mode 100644 signer/lightsigner/errors.py create mode 100644 signer/lightsigner/keys.py create mode 100644 signer/lightsigner/recipe.py create mode 100644 signer/lightsigner/registry.py create mode 100644 signer/lightsigner/signing.py create mode 100644 signer/lightsigner/stamp.py create mode 100644 signer/lightsigner/tools.py create mode 100644 signer/lightsigner/verify.py create mode 100644 signer/registry.json create mode 100644 signer/tests/test_android_integration.py create mode 100644 signer/tests/test_registry.py create mode 100644 signer/tests/test_stamp.py create mode 100644 signer/tests/test_verify.py diff --git a/build.gradle.kts b/build.gradle.kts index f6e86ea9e..848833691 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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" diff --git a/builder/README.md b/builder/README.md index 64e86854c..1e50b702f 100644 --- a/builder/README.md +++ b/builder/README.md @@ -206,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/ diff --git a/builder/bin/build-apk.sh b/builder/bin/build-apk.sh index 4fd91cdd9..53b55b964 100755 --- a/builder/bin/build-apk.sh +++ b/builder/bin/build-apk.sh @@ -147,6 +147,7 @@ python3 -m lightbuilder collect \ --image-digest "$LIGHT_IMAGE_DIGEST" \ --sdk-git-ref "$LIGHT_SDK_GIT_REF" \ --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" diff --git a/builder/lightbuilder/__main__.py b/builder/lightbuilder/__main__.py index ee84725ad..a92008c10 100644 --- a/builder/lightbuilder/__main__.py +++ b/builder/lightbuilder/__main__.py @@ -110,6 +110,7 @@ def cmd_collect(args: argparse.Namespace) -> int: sdk_git_ref=args.sdk_git_ref, build=recipe.Build( image_digest=args.image_digest, + 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, @@ -173,6 +174,7 @@ def _parse(argv: list[str] | None) -> argparse.Namespace: coll.add_argument("--image-digest", required=True) coll.add_argument("--sdk-git-ref", 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) diff --git a/builder/lightbuilder/recipe.py b/builder/lightbuilder/recipe.py index 153adac67..ce74b33de 100644 --- a/builder/lightbuilder/recipe.py +++ b/builder/lightbuilder/recipe.py @@ -25,6 +25,7 @@ class Tool: @dataclass(frozen=True) class Build: image_digest: str + tool_git_ref: str gradle_command: tuple[str, ...] source_date_epoch: int extracted_files: tuple[str, ...] = field(default_factory=tuple) @@ -64,6 +65,7 @@ def write( "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), diff --git a/builder/tests/test_recipe.py b/builder/tests/test_recipe.py index 3fb03edb5..a4a71054f 100644 --- a/builder/tests/test_recipe.py +++ b/builder/tests/test_recipe.py @@ -23,6 +23,7 @@ def test_recipe_uses_shared_tool_and_sdk_objects(tmp_path: Path) -> None: 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",), @@ -38,4 +39,5 @@ def test_recipe_uses_shared_tool_and_sdk_objects(tmp_path: Path) -> None: "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 diff --git a/signer/README.md b/signer/README.md new file mode 100644 index 000000000..d7a1ade79 --- /dev/null +++ b/signer/README.md @@ -0,0 +1,47 @@ +# Light signer + +Offline PoC tooling that registers tool ownership, stamps an attested trust +statement into an unsigned APK, signs the APK, and verifies the result. Requires +Python 3.11+, OpenSSL, a JDK `keytool`, and Android SDK `apksigner` and +`apkanalyzer`. + +Run commands from this directory with `python -m lightsigner`. Use `--help` on +the command or a subcommand for all arguments. + +```sh +export LIGHT_SIGNER_KEY_PASSWORD='replace-me' +# generate signing key +python -m lightsigner keygen --tool-id com.example.tool --keys-dir keys + +# stamp apk with signed trust statement +python -m lightsigner stamp --apk tool-unsigned.apk --recipe recipe.json \ + --registry registry.json --dev-id dev_example --build-id build_example \ + --attestation-key attestation-private.pem --keys-dir keys --out tool-stamped.apk + +# signs and verfies stamped apk +python -m lightsigner sign --apk tool-stamped.apk --recipe recipe.json \ + --registry registry.json --build-id build_example --keys-dir keys \ + --out tool.apk --signed-metadata signed.json + +# performs offline verification of builds +python -m lightsigner verify --apk tool.apk \ + --attestation-public-key attestation-public.pem +``` + +`registry.json` maps a tool ID to its developer and permanent APK signing key: + +```json +{ + "com.example.tool": { + "devId": "dev_example", + "keyId": "com.example.tool" + } +} +``` + +Keys are written below ignored `keys/`. This filesystem keystore is only for +the PoC. Production signing keys must be held by a KMS or HSM. The password is +read only from `LIGHT_SIGNER_KEY_PASSWORD`; commands fail if it is absent. + +Android tools resolve from an explicit override, `PATH`, then the latest tool +under `ANDROID_SDK_ROOT` or `ANDROID_HOME`. diff --git a/signer/lightsigner/__main__.py b/signer/lightsigner/__main__.py new file mode 100644 index 000000000..438e3374d --- /dev/null +++ b/signer/lightsigner/__main__.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from .errors import SignerError +from .keys import generate_key +from .signing import sign_apk +from .stamp import stamp_apk +from .tools import resolve_tool +from .verify import verify_apk + + +def main(argv: list[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + try: + match args.command: + case "keygen": + print(generate_key(args.tool_id, args.keys_dir, resolve_tool("keytool", args.keytool))) + case "stamp": + _ = stamp_apk(apk=args.apk, recipe_path=args.recipe, registry_path=args.registry, + dev_id=args.dev_id, build_id=args.build_id, + attestation_key=args.attestation_key, keys_dir=args.keys_dir, + output=args.out, apkanalyzer=resolve_tool("apkanalyzer", args.apkanalyzer), + issued_at=args.issued_at) + case "sign": + _ = sign_apk(apk=args.apk, recipe_path=args.recipe, registry_path=args.registry, + build_id=args.build_id, keys_dir=args.keys_dir, output=args.out, + metadata_output=args.signed_metadata, + apksigner=resolve_tool("apksigner", args.apksigner)) + case "verify": + _ = verify_apk(apk=args.apk, attestation_public_key=args.attestation_public_key, + apksigner=resolve_tool("apksigner", args.apksigner), + apkanalyzer=resolve_tool("apkanalyzer", args.apkanalyzer), + expected_key_id=args.attestation_key_id) + return 0 + except SignerError as error: + print(f"{error.code}: {error}", file=sys.stderr) + return 1 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m lightsigner") + commands = parser.add_subparsers(dest="command", required=True) + keygen = commands.add_parser("keygen") + _ = keygen.add_argument("--tool-id", required=True) + _ = keygen.add_argument("--keys-dir", type=Path, default=Path("keys")) + _ = keygen.add_argument("--keytool", type=Path) + + stamp = commands.add_parser("stamp") + _artifact_args(stamp) + _ = stamp.add_argument("--dev-id", required=True) + _ = stamp.add_argument("--build-id", required=True) + _ = stamp.add_argument("--attestation-key", required=True, type=Path) + _ = stamp.add_argument("--issued-at") + _ = stamp.add_argument("--apkanalyzer", type=Path) + + sign = commands.add_parser("sign") + _artifact_args(sign) + _ = sign.add_argument("--build-id", required=True) + _ = sign.add_argument("--signed-metadata", required=True, type=Path) + _ = sign.add_argument("--apksigner", type=Path) + + verify = commands.add_parser("verify") + _ = verify.add_argument("--apk", required=True, type=Path) + _ = verify.add_argument("--attestation-public-key", required=True, type=Path) + _ = verify.add_argument("--attestation-key-id", default="light-attest-1") + _ = verify.add_argument("--apksigner", type=Path) + _ = verify.add_argument("--apkanalyzer", type=Path) + return parser + + +def _artifact_args(parser: argparse.ArgumentParser) -> None: + _ = parser.add_argument("--apk", required=True, type=Path) + _ = parser.add_argument("--recipe", required=True, type=Path) + _ = parser.add_argument("--registry", required=True, type=Path) + _ = parser.add_argument("--keys-dir", required=True, type=Path) + _ = parser.add_argument("--out", required=True, type=Path) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/signer/lightsigner/apk.py b/signer/lightsigner/apk.py new file mode 100644 index 000000000..a97397dcd --- /dev/null +++ b/signer/lightsigner/apk.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from .errors import SignerError +from .tools import run_tool + + +@dataclass(frozen=True) +class ApkMetadata: + application_id: str + version_code: int + version_name: str + + +def inspect_apk(apk: Path, apkanalyzer: Path) -> ApkMetadata: + def manifest(field: str) -> str: + return run_tool([str(apkanalyzer), "manifest", field, str(apk)]).strip() + + try: + return ApkMetadata(manifest("application-id"), int(manifest("version-code")), manifest("version-name")) + except ValueError as error: + raise SignerError("invalid_apk_metadata", "APK version code is not an integer") from error + + +def verify_apk_signature(apk: Path, apksigner: Path) -> str: + output = run_tool([str(apksigner), "verify", "--print-certs", str(apk)]) + digests = re.findall(r"(?:Signer #\d+|V\d+(?:\.\d+)? Signer): certificate SHA-256 digest:\s*([0-9a-fA-F:]+)", output) + normalized = {digest.replace(":", "").lower() for digest in digests} + if len(normalized) != 1: + raise SignerError("invalid_apk_signers", f"expected one APK signer, found {len(normalized)}") + return normalized.pop() diff --git a/signer/lightsigner/errors.py b/signer/lightsigner/errors.py new file mode 100644 index 000000000..eaefec3fa --- /dev/null +++ b/signer/lightsigner/errors.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +class SignerError(Exception): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code diff --git a/signer/lightsigner/keys.py b/signer/lightsigner/keys.py new file mode 100644 index 000000000..e86e32752 --- /dev/null +++ b/signer/lightsigner/keys.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path + +from .errors import SignerError +from .registry import validate_tool_id +from .tools import run_tool + + +KEY_ALIAS = "light-tool" +PASSWORD_ENV = "LIGHT_SIGNER_KEY_PASSWORD" + + +def key_directory(keys_dir: Path, key_id: str) -> Path: + return keys_dir / validate_tool_id(key_id) + +def keystore_path(keys_dir: Path, key_id: str) -> Path: + return key_directory(keys_dir, key_id) / "signing.p12" + +def certificate_path(keys_dir: Path, key_id: str) -> Path: + return key_directory(keys_dir, key_id) / "certificate.der" + +def require_password() -> None: + if not os.environ.get(PASSWORD_ENV): + raise SignerError("missing_key_password", f"{PASSWORD_ENV} is required") + +def generate_key(tool_id: str, keys_dir: Path, keytool: Path) -> str: + _ = validate_tool_id(tool_id) + require_password() + directory = key_directory(keys_dir, tool_id) + keystore = keystore_path(keys_dir, tool_id) + certificate = certificate_path(keys_dir, tool_id) + if directory.exists(): + raise SignerError("key_exists", f"key already exists for {tool_id}") + directory.mkdir(parents=True, mode=0o700) + try: + _ = run_tool( + [ + str(keytool), + "-genkeypair", + "-alias", + KEY_ALIAS, + "-keyalg", + "EC", + "-groupname", + "secp256r1", + "-sigalg", + "SHA256withECDSA", + "-dname", + f"CN={tool_id},O=Light", + "-validity", + "36500", + "-keystore", + str(keystore), + "-storetype", + "PKCS12", + "-storepass:env", + PASSWORD_ENV, + "-keypass:env", + PASSWORD_ENV, + ] + ) + _ = run_tool( + [ + str(keytool), + "-exportcert", + "-alias", + KEY_ALIAS, + "-keystore", + str(keystore), + "-storetype", + "PKCS12", + "-storepass:env", + PASSWORD_ENV, + "-file", + str(certificate), + ] + ) + os.chmod(keystore, 0o600) + os.chmod(certificate, 0o644) + return certificate_sha256(certificate) + except Exception: + for path in (certificate, keystore): + path.unlink(missing_ok=True) + directory.rmdir() + raise + + +def certificate_sha256(certificate: Path) -> str: + try: + return hashlib.sha256(certificate.read_bytes()).hexdigest() + except OSError as error: + raise SignerError("missing_certificate", f"cannot read certificate: {certificate}") from error diff --git a/signer/lightsigner/recipe.py b/signer/lightsigner/recipe.py new file mode 100644 index 000000000..0d144b5d7 --- /dev/null +++ b/signer/lightsigner/recipe.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from pathlib import Path + +from .errors import SignerError +from .registry import _load_json_object, validate_tool_id + + +@dataclass(frozen=True) +class ToolRecipe: + id: str + version_code: int + version_name: str + git_url: str + git_commit: str + + +@dataclass(frozen=True) +class Recipe: + tool: ToolRecipe + sdk_git_ref: str + unsigned_sha256: str + + +def load_recipe(path: Path) -> Recipe: + document = _load_json_object(path, "recipe") + tool = document.get("tool") + artifact = document.get("artifact") + sdk_git_ref = document.get("sdkGitRef") + if not isinstance(tool, dict) or not isinstance(artifact, dict) or not isinstance(sdk_git_ref, str): + raise SignerError("invalid_recipe", "recipe requires tool, artifact, and sdkGitRef") + values = (tool.get("id"), tool.get("versionCode"), tool.get("versionName"), tool.get("gitUrl"), tool.get("gitCommit")) + if not isinstance(values[0], str) or not isinstance(values[1], int) or isinstance(values[1], bool): + raise SignerError("invalid_recipe", "recipe tool id/versionCode are invalid") + if not all(isinstance(value, str) for value in values[2:]): + raise SignerError("invalid_recipe", "recipe tool strings are invalid") + digest = artifact.get("sha256") + if not isinstance(digest, str) or len(digest) != 64: + raise SignerError("invalid_recipe", "recipe artifact.sha256 is invalid") + validate_tool_id(values[0]) + return Recipe(ToolRecipe(*values), sdk_git_ref, digest.lower()) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1 << 20), b""): + digest.update(chunk) + except OSError as error: + raise SignerError("artifact_unreadable", f"cannot read artifact: {path}") from error + return digest.hexdigest() diff --git a/signer/lightsigner/registry.py b/signer/lightsigner/registry.py new file mode 100644 index 000000000..0b9f08421 --- /dev/null +++ b/signer/lightsigner/registry.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .errors import SignerError + + +TOOL_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") + + +@dataclass(frozen=True) +class RegistryEntry: + dev_id: str + key_id: str + + +def validate_tool_id(tool_id: str) -> str: + if not TOOL_ID_PATTERN.fullmatch(tool_id): + raise SignerError("invalid_tool_id", f"invalid tool id: {tool_id}") + return tool_id + + +def load_registry(path: Path) -> dict[str, RegistryEntry]: + raw = _load_json_object(path, "registry") + registry: dict[str, RegistryEntry] = {} + for tool_id, value in raw.items(): + validate_tool_id(tool_id) + if not isinstance(value, dict): + raise SignerError("invalid_registry", f"registry entry for {tool_id} must be an object") + if set(value) != {"devId", "keyId"}: + raise SignerError("invalid_registry", f"registry entry for {tool_id} has invalid fields") + dev_id = value["devId"] + key_id = value["keyId"] + if not isinstance(dev_id, str) or not dev_id: + raise SignerError("invalid_registry", f"registry devId for {tool_id} must be a string") + if not isinstance(key_id, str) or not TOOL_ID_PATTERN.fullmatch(key_id): + raise SignerError("invalid_registry", f"registry keyId for {tool_id} is invalid") + registry[tool_id] = RegistryEntry(dev_id=dev_id, key_id=key_id) + return registry + + +def require_owner( + registry: dict[str, RegistryEntry], tool_id: str, requesting_dev_id: str +) -> RegistryEntry: + entry = registry.get(tool_id) + if entry is None: + raise SignerError("unregistered_tool", f"tool is not registered: {tool_id}") + if entry.dev_id != requesting_dev_id: + raise SignerError("wrong_developer", f"tool {tool_id} is registered to another developer") + return entry + + +def _load_json_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique_object) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise SignerError(f"invalid_{label}", f"cannot read {label}: {error}") from error + if not isinstance(value, dict): + raise SignerError(f"invalid_{label}", f"{label} must be a JSON object") + return value + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise SignerError("duplicate_json_key", f"duplicate JSON key: {key}") + result[key] = value + return result diff --git a/signer/lightsigner/signing.py b/signer/lightsigner/signing.py new file mode 100644 index 000000000..844d0ceae --- /dev/null +++ b/signer/lightsigner/signing.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from .apk import verify_apk_signature +from .errors import SignerError +from .keys import KEY_ALIAS, PASSWORD_ENV, keystore_path, require_password +from .recipe import load_recipe, sha256 +from .registry import load_registry +from .stamp import read_statement +from .tools import run_tool + +# sign apk using android sdk apksigner +def sign_apk(*, apk: Path, recipe_path: Path, registry_path: Path, build_id: str, + keys_dir: Path, output: Path, metadata_output: Path, apksigner: Path) -> dict[str, object]: + if apk.resolve() == output.resolve(): + raise SignerError("output_overwrites_input", "output must differ from input") + require_password() + + recipe = load_recipe(recipe_path) + entry = load_registry(registry_path).get(recipe.tool.id) + if entry is None: + raise SignerError("unregistered_tool", f"tool is not registered: {recipe.tool.id}") + output.parent.mkdir(parents=True, exist_ok=True) + + run_tool([ + str(apksigner), "sign", "--ks", str(keystore_path(keys_dir, entry.key_id)), + "--ks-key-alias", KEY_ALIAS, "--ks-pass", f"env:{PASSWORD_ENV}", + "--key-pass", f"env:{PASSWORD_ENV}", "--out", str(output), str(apk), + ]) + + signer_hash = verify_apk_signature(output, apksigner) + if read_statement(output).get("signerSha256") != signer_hash: + output.unlink(missing_ok=True) + raise SignerError("signer_mismatch", "statement signer does not match APK signing key") + + metadata: dict[str, object] = { + "buildId": build_id, + "unsignedSha256": recipe.unsigned_sha256, + "apkSha256": sha256(output), + "signerSha256": signer_hash, + } + metadata_output.parent.mkdir(parents=True, exist_ok=True) + metadata_output.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + return metadata diff --git a/signer/lightsigner/stamp.py b/signer/lightsigner/stamp.py new file mode 100644 index 000000000..2952516a2 --- /dev/null +++ b/signer/lightsigner/stamp.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import base64 +import json +import tempfile +import zipfile +from datetime import UTC, datetime +from pathlib import Path + +from .apk import inspect_apk +from .errors import SignerError +from .keys import certificate_path, certificate_sha256 +from .recipe import Recipe, load_recipe, sha256 +from .registry import load_registry, require_owner +from .statement import canonical_bytes, sign_ed25519 + + +STATEMENT_PATH = "META-INF/light-trust.json" +ATTESTATION_KEY_ID = "light-attest-1" + + +def stamp_apk(*, apk: Path, recipe_path: Path, registry_path: Path, dev_id: str, + build_id: str, attestation_key: Path, keys_dir: Path, output: Path, + apkanalyzer: Path, issued_at: str | None = None) -> dict[str, object]: + # verify that unsigned apk matches build recipe and stamp parameters + recipe = load_recipe(recipe_path) + actual_hash = sha256(apk) + if actual_hash != recipe.unsigned_sha256: + raise SignerError("unsigned_hash_mismatch", "APK SHA-256 does not match recipe") + _check_metadata(recipe, inspect_apk(apk, apkanalyzer)) + entry = require_owner(load_registry(registry_path), recipe.tool.id, dev_id) + + # build and sign trust statement + statement: dict[str, object] = { + "schemaVersion": 1, + "tool": { + "id": recipe.tool.id, + "versionCode": recipe.tool.version_code, + "versionName": recipe.tool.version_name, + "gitUrl": recipe.tool.git_url, + "gitCommit": recipe.tool.git_commit, + }, + "sdkGitRef": recipe.sdk_git_ref, + "devId": dev_id, + "signerSha256": certificate_sha256(certificate_path(keys_dir, entry.key_id)), + "buildId": build_id, + "unsignedSha256": actual_hash, + "issuedAt": issued_at or datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"), + } + signature = sign_ed25519(canonical_bytes(statement), attestation_key) + statement["attestation"] = { + "keyId": ATTESTATION_KEY_ID, + "alg": "Ed25519", + "sig": base64.b64encode(signature).decode("ascii"), + } + + # inject trust statement in build + _rewrite_zip(apk, output, json.dumps(statement, indent=2, sort_keys=True).encode() + b"\n") + return statement + + +def read_statement(apk: Path) -> dict[str, object]: + try: + with zipfile.ZipFile(apk) as archive: + matches = [info for info in archive.infolist() if info.filename == STATEMENT_PATH] + if len(matches) != 1: + raise SignerError("invalid_statement_count", f"expected one trust statement, found {len(matches)}") + value = json.loads(archive.read(matches[0]), object_pairs_hook=_unique_object) + except (OSError, zipfile.BadZipFile, json.JSONDecodeError, UnicodeError) as error: + raise SignerError("invalid_statement", "trust statement is unreadable") from error + if not isinstance(value, dict): + raise SignerError("invalid_statement", "trust statement must be an object") + return value + + +def _check_metadata(recipe: Recipe, metadata: object) -> None: + expected = (recipe.tool.id, recipe.tool.version_code, recipe.tool.version_name) + actual = (metadata.application_id, metadata.version_code, metadata.version_name) + if actual != expected: + raise SignerError("apk_metadata_mismatch", f"APK metadata {actual!r} does not match recipe {expected!r}") + + +def _rewrite_zip(source: Path, output: Path, statement: bytes) -> None: + if source.resolve() == output.resolve(): + raise SignerError("output_overwrites_input", "output must differ from input") + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=output.parent, delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + with zipfile.ZipFile(source) as original, zipfile.ZipFile(temporary_path, "w") as rewritten: + for info in original.infolist(): + if info.filename != STATEMENT_PATH: + rewritten.writestr(info, original.read(info)) + rewritten.writestr(STATEMENT_PATH, statement, compress_type=zipfile.ZIP_DEFLATED) + _ = temporary_path.replace(output) + except Exception: + temporary_path.unlink(missing_ok=True) + raise + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise SignerError("invalid_statement", f"duplicate field: {key}") + result[key] = value + return result diff --git a/signer/lightsigner/tools.py b/signer/lightsigner/tools.py new file mode 100644 index 000000000..0c98b7d8d --- /dev/null +++ b/signer/lightsigner/tools.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +from .errors import SignerError + + +def resolve_tool(name: str, explicit: Path | None = None) -> Path: + if explicit is not None: + if explicit.is_file(): + return explicit.resolve() + raise SignerError("tool_not_found", f"{name} not found at {explicit}") + + found = shutil.which(name) + if found: + return Path(found).resolve() + + if name in {"apksigner", "apkanalyzer"}: + for sdk_env in ("ANDROID_SDK_ROOT", "ANDROID_HOME"): + sdk_value = os.environ.get(sdk_env) + if not sdk_value: + continue + candidate = _find_android_tool(Path(sdk_value), name) + if candidate is not None: + return candidate + + raise SignerError("tool_not_found", f"{name} was not found") + + +def run_tool(command: list[str], *, env: dict[str, str] | None = None) -> str: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + raise SignerError("external_tool_failed", f"{Path(command[0]).name}: {detail}") + return result.stdout + + +def _find_android_tool(sdk: Path, name: str) -> Path | None: + if name == "apkanalyzer": + candidates = list((sdk / "cmdline-tools").glob("*/bin/apkanalyzer")) + else: + candidates = list((sdk / "build-tools").glob(f"*/{name}")) + existing = [candidate for candidate in candidates if candidate.is_file()] + return max(existing, key=_android_version_key, default=None) + + +def _android_version_key(path: Path) -> tuple[int, ...]: + version = path.parent.parent.name if path.parent.name == "bin" else path.parent.name + if version == "latest": + return (1_000_000,) + numbers = re.findall(r"\d+", version) + return tuple(int(number) for number in numbers) if numbers else (0,) diff --git a/signer/lightsigner/verify.py b/signer/lightsigner/verify.py new file mode 100644 index 000000000..c9de5067b --- /dev/null +++ b/signer/lightsigner/verify.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import base64 +import binascii +from pathlib import Path + +from .apk import inspect_apk, verify_apk_signature +from .errors import SignerError +from .stamp import ATTESTATION_KEY_ID, read_statement +from .statement import canonical_bytes, verify_ed25519 + + +def verify_apk(*, apk: Path, attestation_public_key: Path, apksigner: Path, + apkanalyzer: Path, expected_key_id: str = ATTESTATION_KEY_ID) -> dict[str, object]: + signer_hash = verify_apk_signature(apk, apksigner) + statement = read_statement(apk) + attestation = statement.get("attestation") + + if not isinstance(attestation, dict): + raise SignerError("invalid_attestation", "attestation is missing") + + if attestation.get("alg") != "Ed25519" or attestation.get("keyId") != expected_key_id: + raise SignerError("invalid_attestation", "attestation algorithm or key ID is invalid") + + encoded = attestation.get("sig") + if not isinstance(encoded, str): + raise SignerError("invalid_attestation", "attestation signature is invalid") + + try: + signature = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as error: + raise SignerError("invalid_attestation", "attestation signature is not base64") from error + + try: + verified = verify_ed25519(canonical_bytes(statement), signature, attestation_public_key) + except ValueError as error: + raise SignerError("invalid_statement", str(error)) from error + if not verified: + raise SignerError("attestation_failed", "trust statement signature is invalid") + if statement.get("signerSha256") != signer_hash: + raise SignerError("signer_mismatch", "statement signer does not match APK signer") + + tool = statement.get("tool") + if not isinstance(tool, dict): + raise SignerError("invalid_statement", "statement tool is missing") + + metadata = inspect_apk(apk, apkanalyzer) + if (tool.get("id"), tool.get("versionCode"), tool.get("versionName")) != ( + metadata.application_id, metadata.version_code, metadata.version_name + ): + raise SignerError("statement_apk_mismatch", "statement tool metadata does not match APK") + return statement diff --git a/signer/registry.json b/signer/registry.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/signer/registry.json @@ -0,0 +1 @@ +{} diff --git a/signer/tests/test_android_integration.py b/signer/tests/test_android_integration.py new file mode 100644 index 000000000..9a34f2106 --- /dev/null +++ b/signer/tests/test_android_integration.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from lightsigner.keys import generate_key +from lightsigner.errors import SignerError +from lightsigner.recipe import sha256 +from lightsigner.signing import sign_apk +from lightsigner.stamp import stamp_apk +from lightsigner.tools import resolve_tool, run_tool +from lightsigner.verify import verify_apk + +# Runs the complete workflow using real JDK and Android tools. +## In the future this can be extended to do the full build -> stamp -> sign -> verify flow +@pytest.mark.skipif(os.environ.get("LIGHT_SIGNER_ANDROID_INTEGRATION") != "1", reason="opt-in Android integration") +def test_real_android_signing_round_trip(tmp_path, monkeypatch) -> None: + repository = Path(__file__).resolve().parents[2] + unsigned = repository / "tool/build/outputs/apk/release/tool-release-unsigned.apk" + if not unsigned.is_file(): + pytest.skip("build :tool:assembleRelease -DlightSdk.unsigned=true first") + + # Load Android and OpenSSL tooling + sdk = Path(os.environ.get("ANDROID_SDK_ROOT", Path.home() / "Library/Android/sdk")) + monkeypatch.setenv("ANDROID_SDK_ROOT", str(sdk)) + monkeypatch.setenv("LIGHT_SIGNER_KEY_PASSWORD", "integration-test-only") + keytool_name = shutil.which("keytool") + openssl = shutil.which("openssl") + if not keytool_name or not openssl: + pytest.skip("keytool and openssl required") + keytool = Path(keytool_name) + apksigner = resolve_tool("apksigner") + apkanalyzer = resolve_tool("apkanalyzer") + assert run_tool([str(apkanalyzer), "manifest", "target-sdk", str(unsigned)]).strip() == "34" + + # Generate attestation key + private_key = tmp_path / "attestation-private.pem" + public_key = tmp_path / "attestation-public.pem" + subprocess.run([openssl, "genpkey", "-algorithm", "Ed25519", "-out", private_key], check=True) + subprocess.run([openssl, "pkey", "-in", private_key, "-pubout", "-out", public_key], check=True) + + # Generate per-tool signing key + tool_id = "com.thelightphone.app" + keys_dir = tmp_path / "keys" + generate_key(tool_id, keys_dir, keytool) + with pytest.raises(SignerError) as duplicate_key: + generate_key(tool_id, keys_dir, keytool) + assert duplicate_key.value.code == "key_exists" + + # Stamp the apk with a trust statement + ## we'll need to mock the build recipe + recipe = tmp_path / "recipe.json" + recipe.write_text(json.dumps({ + "schemaVersion": 1, + "artifact": {"sha256": sha256(unsigned)}, + "tool": {"id": tool_id, "versionCode": 1, "versionName": "1.0.0", "gitUrl": "https://example.test/tool", "gitCommit": "a" * 40}, + "sdkGitRef": "integration", + })) + ## and mock the key registry entry + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({tool_id: {"devId": "dev_integration", "keyId": tool_id}})) + stamped = tmp_path / "stamped.apk" + signed = tmp_path / "signed.apk" + signed_metadata = tmp_path / "signed.json" + stamp_apk(apk=unsigned, recipe_path=recipe, registry_path=registry, dev_id="dev_integration", + build_id="build_integration", attestation_key=private_key, keys_dir=keys_dir, + output=stamped, apkanalyzer=apkanalyzer) + + # Sign apk after stamped + result = sign_apk(apk=stamped, recipe_path=recipe, registry_path=registry, + build_id="build_integration", keys_dir=keys_dir, output=signed, + metadata_output=signed_metadata, apksigner=apksigner) + + # Verify stamped and signed apk + statement = verify_apk(apk=signed, attestation_public_key=public_key, + apksigner=apksigner, apkanalyzer=apkanalyzer) + assert statement["signerSha256"] == result["signerSha256"] + assert result["apkSha256"] == sha256(signed) + + foreign_private = tmp_path / "foreign-private.pem" + foreign_public = tmp_path / "foreign-public.pem" + subprocess.run([openssl, "genpkey", "-algorithm", "Ed25519", "-out", foreign_private], check=True) + subprocess.run([openssl, "pkey", "-in", foreign_private, "-pubout", "-out", foreign_public], check=True) + with pytest.raises(SignerError) as foreign_key: + verify_apk(apk=signed, attestation_public_key=foreign_public, + apksigner=apksigner, apkanalyzer=apkanalyzer) + assert foreign_key.value.code == "attestation_failed" diff --git a/signer/tests/test_registry.py b/signer/tests/test_registry.py new file mode 100644 index 000000000..7b9168e49 --- /dev/null +++ b/signer/tests/test_registry.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import json + +import pytest + +from lightsigner.errors import SignerError +from lightsigner.registry import load_registry, require_owner + + +def test_registry_owner(tmp_path) -> None: + path = tmp_path / "registry.json" + path.write_text(json.dumps({"com.example.tool": {"devId": "dev_1", "keyId": "com.example.tool"}})) + entry = require_owner(load_registry(path), "com.example.tool", "dev_1") + assert entry.key_id == "com.example.tool" + + +@pytest.mark.parametrize( + ("tool_id", "dev_id", "code"), + [("com.missing.tool", "dev_1", "unregistered_tool"), ("com.example.tool", "dev_2", "wrong_developer")], +) +def test_registry_rejects_invalid_owner(tmp_path, tool_id, dev_id, code) -> None: + path = tmp_path / "registry.json" + path.write_text(json.dumps({"com.example.tool": {"devId": "dev_1", "keyId": "com.example.tool"}})) + with pytest.raises(SignerError) as failure: + require_owner(load_registry(path), tool_id, dev_id) + assert failure.value.code == code diff --git a/signer/tests/test_stamp.py b/signer/tests/test_stamp.py new file mode 100644 index 000000000..9e516201e --- /dev/null +++ b/signer/tests/test_stamp.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import hashlib +import json +import zipfile + +import pytest + +from lightsigner.apk import ApkMetadata +from lightsigner.errors import SignerError +from lightsigner.stamp import STATEMENT_PATH, read_statement, stamp_apk + + +def _inputs(tmp_path): + apk = tmp_path / "unsigned.apk" + with zipfile.ZipFile(apk, "w") as archive: + archive.writestr("classes.dex", b"dex", compress_type=zipfile.ZIP_STORED) + archive.writestr("assets/data", b"payload", compress_type=zipfile.ZIP_DEFLATED) + digest = hashlib.sha256(apk.read_bytes()).hexdigest() + recipe = tmp_path / "recipe.json" + recipe.write_text(json.dumps({ + "schemaVersion": 1, + "artifact": {"sha256": digest}, + "tool": {"id": "com.example.tool", "versionCode": 3, "versionName": "1.2", "gitUrl": "https://example.test/tool", "gitCommit": "a" * 40}, + "sdkGitRef": "v1.0", + })) + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"com.example.tool": {"devId": "dev_1", "keyId": "com.example.tool"}})) + keys = tmp_path / "keys" / "com.example.tool" + keys.mkdir(parents=True) + (keys / "certificate.der").write_bytes(b"certificate") + return apk, recipe, registry + + +def test_stamp_preserves_entries_and_attests(tmp_path, monkeypatch) -> None: + apk, recipe, registry = _inputs(tmp_path) + output = tmp_path / "stamped.apk" + monkeypatch.setattr("lightsigner.stamp.inspect_apk", lambda *_: ApkMetadata("com.example.tool", 3, "1.2")) + monkeypatch.setattr("lightsigner.stamp.sign_ed25519", lambda *_: b"signature") + stamp_apk(apk=apk, recipe_path=recipe, registry_path=registry, dev_id="dev_1", build_id="build_1", + attestation_key=tmp_path / "unused", keys_dir=tmp_path / "keys", output=output, + apkanalyzer=tmp_path / "unused", issued_at="2026-08-25T00:00:00Z") + with zipfile.ZipFile(apk) as before, zipfile.ZipFile(output) as after: + for name in ("classes.dex", "assets/data"): + assert after.read(name) == before.read(name) + assert after.getinfo(name).compress_type == before.getinfo(name).compress_type + assert [info.filename for info in after.infolist()].count(STATEMENT_PATH) == 1 + assert read_statement(output)["unsignedSha256"] == hashlib.sha256(apk.read_bytes()).hexdigest() + + +def test_stamp_checks_apk_metadata_before_registry(tmp_path, monkeypatch) -> None: + apk, recipe, registry = _inputs(tmp_path) + registry.write_text("{}") + monkeypatch.setattr("lightsigner.stamp.inspect_apk", lambda *_: ApkMetadata("com.other.tool", 3, "1.2")) + with pytest.raises(SignerError) as failure: + stamp_apk(apk=apk, recipe_path=recipe, registry_path=registry, dev_id="dev_1", build_id="build_1", + attestation_key=tmp_path / "unused", keys_dir=tmp_path / "keys", output=tmp_path / "out.apk", + apkanalyzer=tmp_path / "unused") + assert failure.value.code == "apk_metadata_mismatch" diff --git a/signer/tests/test_verify.py b/signer/tests/test_verify.py new file mode 100644 index 000000000..8d1fea4fa --- /dev/null +++ b/signer/tests/test_verify.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import warnings +import zipfile +from pathlib import Path + +import pytest + +from lightsigner.apk import ApkMetadata +from lightsigner.errors import SignerError +from lightsigner.stamp import STATEMENT_PATH, read_statement +from lightsigner.verify import verify_apk + + +VECTORS = Path(__file__).parent / "vectors" + + +def _apk(tmp_path, statement=None, copies=1): + apk = tmp_path / "tool.apk" + with zipfile.ZipFile(apk, "w") as archive: + archive.writestr("classes.dex", b"dex") + for _ in range(copies): + if statement is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + archive.writestr(STATEMENT_PATH, json.dumps(statement)) + return apk + + +@pytest.mark.parametrize(("statement", "copies"), [(None, 1), ({}, 2)]) +def test_read_statement_requires_exactly_one(tmp_path, statement, copies) -> None: + with pytest.raises(SignerError) as failure: + read_statement(_apk(tmp_path, statement, copies)) + assert failure.value.code == "invalid_statement_count" + + +def test_verify_rejects_edited_statement(tmp_path, monkeypatch) -> None: + statement = json.loads((VECTORS / "statement.json").read_text()) + statement["buildId"] = "edited" + apk = _apk(tmp_path, statement) + monkeypatch.setattr("lightsigner.verify.verify_apk_signature", lambda *_: "c" * 64) + monkeypatch.setattr("lightsigner.verify.inspect_apk", lambda *_: ApkMetadata("com.example.tool", 3, "1.2.0")) + with pytest.raises(SignerError) as failure: + verify_apk(apk=apk, attestation_public_key=VECTORS / "test-attestation-public.pem", + apksigner=tmp_path / "unused", apkanalyzer=tmp_path / "unused", + expected_key_id="test-attestation-1") + assert failure.value.code == "attestation_failed" + + +def test_verify_rejects_statement_transplanted_to_another_apk(tmp_path, monkeypatch) -> None: + statement = json.loads((VECTORS / "statement.json").read_text()) + apk = _apk(tmp_path, statement) + monkeypatch.setattr("lightsigner.verify.verify_apk_signature", lambda *_: "c" * 64) + monkeypatch.setattr("lightsigner.verify.inspect_apk", lambda *_: ApkMetadata("com.other.tool", 3, "1.2.0")) + with pytest.raises(SignerError) as failure: + verify_apk(apk=apk, attestation_public_key=VECTORS / "test-attestation-public.pem", + apksigner=tmp_path / "unused", apkanalyzer=tmp_path / "unused", + expected_key_id="test-attestation-1") + assert failure.value.code == "statement_apk_mismatch" From 021be33487705eb46cb9c6b6fd8e53ae0f3b1e2a Mon Sep 17 00:00:00 2001 From: Gustavo Brunoro Date: Wed, 26 Aug 2026 18:05:58 -0300 Subject: [PATCH 3/3] refactor: clarify tool signer terminology --- builder/lightbuilder/__main__.py | 3 +- builder/tests/test_extract.py | 2 - signer/README.md | 12 +-- signer/lightsigner/__main__.py | 99 ++++++++++++++++--- signer/lightsigner/apk.py | 32 +++++- .../{recipe.py => build_recipe.py} | 30 +++--- signer/lightsigner/keys.py | 5 +- signer/lightsigner/registry.py | 9 +- signer/lightsigner/signing.py | 20 ++-- signer/lightsigner/stamp.py | 89 +++++++---------- signer/lightsigner/tools.py | 3 +- .../{statement.py => trust_statement.py} | 8 +- signer/lightsigner/verify.py | 24 ++--- signer/tests/conftest.py | 1 - signer/tests/test_android_integration.py | 18 ++-- signer/tests/test_registry.py | 1 - signer/tests/test_stamp.py | 23 +++-- signer/tests/test_statement.py | 58 ----------- signer/tests/test_trust_statement.py | 57 +++++++++++ signer/tests/test_verify.py | 31 +++--- signer/tests/vectors/generate.py | 10 +- 21 files changed, 311 insertions(+), 224 deletions(-) rename signer/lightsigner/{recipe.py => build_recipe.py} (62%) rename signer/lightsigner/{statement.py => trust_statement.py} (91%) delete mode 100644 signer/tests/test_statement.py create mode 100644 signer/tests/test_trust_statement.py diff --git a/builder/lightbuilder/__main__.py b/builder/lightbuilder/__main__.py index a92008c10..3e49c6b36 100644 --- a/builder/lightbuilder/__main__.py +++ b/builder/lightbuilder/__main__.py @@ -23,10 +23,11 @@ import json import shutil import sys -import tomllib import zipfile from pathlib import Path +import tomllib + from . import extract, recipe diff --git a/builder/tests/test_extract.py b/builder/tests/test_extract.py index 9d934d75c..6b07602b5 100644 --- a/builder/tests/test_extract.py +++ b/builder/tests/test_extract.py @@ -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) diff --git a/signer/README.md b/signer/README.md index d7a1ade79..92dd13388 100644 --- a/signer/README.md +++ b/signer/README.md @@ -10,20 +10,20 @@ the command or a subcommand for all arguments. ```sh export LIGHT_SIGNER_KEY_PASSWORD='replace-me' -# generate signing key +# Generate the APK signing key. python -m lightsigner keygen --tool-id com.example.tool --keys-dir keys -# stamp apk with signed trust statement -python -m lightsigner stamp --apk tool-unsigned.apk --recipe recipe.json \ +# Stamp the APK with a signed trust statement. +python -m lightsigner stamp --apk tool-unsigned.apk --build-recipe recipe.json \ --registry registry.json --dev-id dev_example --build-id build_example \ --attestation-key attestation-private.pem --keys-dir keys --out tool-stamped.apk -# signs and verfies stamped apk -python -m lightsigner sign --apk tool-stamped.apk --recipe recipe.json \ +# Sign and verify the stamped APK. +python -m lightsigner sign --apk tool-stamped.apk --build-recipe recipe.json \ --registry registry.json --build-id build_example --keys-dir keys \ --out tool.apk --signed-metadata signed.json -# performs offline verification of builds +# Perform offline verification of the APK. python -m lightsigner verify --apk tool.apk \ --attestation-public-key attestation-public.pem ``` diff --git a/signer/lightsigner/__main__.py b/signer/lightsigner/__main__.py index 438e3374d..bdcd548a0 100644 --- a/signer/lightsigner/__main__.py +++ b/signer/lightsigner/__main__.py @@ -3,6 +3,7 @@ import argparse import sys from pathlib import Path +from typing import Protocol, cast from .errors import SignerError from .keys import generate_key @@ -12,29 +13,95 @@ from .verify import verify_apk +class KeygenArgs(Protocol): + tool_id: str + keys_dir: Path + keytool: Path | None + + +class StampArgs(Protocol): + apk: Path + build_recipe: Path + registry: Path + keys_dir: Path + out: Path + dev_id: str + build_id: str + attestation_key: Path + issued_at: str | None + apkanalyzer: Path | None + + +class SignArgs(Protocol): + apk: Path + build_recipe: Path + registry: Path + keys_dir: Path + out: Path + build_id: str + signed_metadata: Path + apksigner: Path | None + + +class VerifyArgs(Protocol): + apk: Path + attestation_public_key: Path + attestation_key_id: str + apksigner: Path | None + apkanalyzer: Path | None + + def main(argv: list[str] | None = None) -> int: parser = _parser() args = parser.parse_args(argv) try: - match args.command: + match cast(str, args.command): case "keygen": - print(generate_key(args.tool_id, args.keys_dir, resolve_tool("keytool", args.keytool))) + keygen_args = cast(KeygenArgs, cast(object, args)) + print( + generate_key( + keygen_args.tool_id, + keygen_args.keys_dir, + resolve_tool("keytool", keygen_args.keytool), + ) + ) case "stamp": - _ = stamp_apk(apk=args.apk, recipe_path=args.recipe, registry_path=args.registry, - dev_id=args.dev_id, build_id=args.build_id, - attestation_key=args.attestation_key, keys_dir=args.keys_dir, - output=args.out, apkanalyzer=resolve_tool("apkanalyzer", args.apkanalyzer), - issued_at=args.issued_at) + stamp_args = cast(StampArgs, cast(object, args)) + _ = stamp_apk( + apk=stamp_args.apk, + build_recipe_path=stamp_args.build_recipe, + registry_path=stamp_args.registry, + dev_id=stamp_args.dev_id, + build_id=stamp_args.build_id, + attestation_key=stamp_args.attestation_key, + keys_dir=stamp_args.keys_dir, + output=stamp_args.out, + apkanalyzer=resolve_tool("apkanalyzer", stamp_args.apkanalyzer), + issued_at=stamp_args.issued_at, + ) case "sign": - _ = sign_apk(apk=args.apk, recipe_path=args.recipe, registry_path=args.registry, - build_id=args.build_id, keys_dir=args.keys_dir, output=args.out, - metadata_output=args.signed_metadata, - apksigner=resolve_tool("apksigner", args.apksigner)) + sign_args = cast(SignArgs, cast(object, args)) + _ = sign_apk( + apk=sign_args.apk, + build_recipe_path=sign_args.build_recipe, + registry_path=sign_args.registry, + build_id=sign_args.build_id, + keys_dir=sign_args.keys_dir, + output=sign_args.out, + metadata_output=sign_args.signed_metadata, + apksigner=resolve_tool("apksigner", sign_args.apksigner), + ) case "verify": - _ = verify_apk(apk=args.apk, attestation_public_key=args.attestation_public_key, - apksigner=resolve_tool("apksigner", args.apksigner), - apkanalyzer=resolve_tool("apkanalyzer", args.apkanalyzer), - expected_key_id=args.attestation_key_id) + verify_args = cast(VerifyArgs, cast(object, args)) + _ = verify_apk( + apk=verify_args.apk, + attestation_public_key=verify_args.attestation_public_key, + apksigner=resolve_tool("apksigner", verify_args.apksigner), + apkanalyzer=resolve_tool("apkanalyzer", verify_args.apkanalyzer), + expected_key_id=verify_args.attestation_key_id, + ) + case unknown: + parser.error(f"unknown command: {unknown}") return 0 except SignerError as error: print(f"{error.code}: {error}", file=sys.stderr) @@ -74,7 +141,7 @@ def _parser() -> argparse.ArgumentParser: def _artifact_args(parser: argparse.ArgumentParser) -> None: _ = parser.add_argument("--apk", required=True, type=Path) - _ = parser.add_argument("--recipe", required=True, type=Path) + _ = parser.add_argument("--build-recipe", required=True, type=Path) _ = parser.add_argument("--registry", required=True, type=Path) _ = parser.add_argument("--keys-dir", required=True, type=Path) _ = parser.add_argument("--out", required=True, type=Path) diff --git a/signer/lightsigner/apk.py b/signer/lightsigner/apk.py index a97397dcd..8c7fe5e1c 100644 --- a/signer/lightsigner/apk.py +++ b/signer/lightsigner/apk.py @@ -1,12 +1,16 @@ from __future__ import annotations +import json import re +import zipfile from dataclasses import dataclass from pathlib import Path from .errors import SignerError from .tools import run_tool +TRUST_STATEMENT_PATH = "META-INF/light-trust.json" + @dataclass(frozen=True) class ApkMetadata: @@ -28,7 +32,33 @@ def manifest(field: str) -> str: def verify_apk_signature(apk: Path, apksigner: Path) -> str: output = run_tool([str(apksigner), "verify", "--print-certs", str(apk)]) digests = re.findall(r"(?:Signer #\d+|V\d+(?:\.\d+)? Signer): certificate SHA-256 digest:\s*([0-9a-fA-F:]+)", output) - normalized = {digest.replace(":", "").lower() for digest in digests} + normalized: set[str] = {digest.replace(":", "").lower() for digest in digests} if len(normalized) != 1: raise SignerError("invalid_apk_signers", f"expected one APK signer, found {len(normalized)}") return normalized.pop() + + +def read_trust_statement(apk: Path) -> dict[str, object]: + try: + with zipfile.ZipFile(apk) as archive: + matches = [info for info in archive.infolist() if info.filename == TRUST_STATEMENT_PATH] + if len(matches) != 1: + raise SignerError( + "invalid_statement_count", + f"expected one trust statement, found {len(matches)}", + ) + value = json.loads(archive.read(matches[0]), object_pairs_hook=_unique_object) + except (OSError, zipfile.BadZipFile, json.JSONDecodeError, UnicodeError) as error: + raise SignerError("invalid_statement", "trust statement is unreadable") from error + if not isinstance(value, dict): + raise SignerError("invalid_statement", "trust statement must be an object") + return value + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise SignerError("invalid_statement", f"duplicate field: {key}") + result[key] = value + return result diff --git a/signer/lightsigner/recipe.py b/signer/lightsigner/build_recipe.py similarity index 62% rename from signer/lightsigner/recipe.py rename to signer/lightsigner/build_recipe.py index 0d144b5d7..5b1571fe8 100644 --- a/signer/lightsigner/recipe.py +++ b/signer/lightsigner/build_recipe.py @@ -5,11 +5,11 @@ from pathlib import Path from .errors import SignerError -from .registry import _load_json_object, validate_tool_id +from .registry import load_json_object, validate_tool_id @dataclass(frozen=True) -class ToolRecipe: +class ToolBuildRecipe: id: str version_code: int version_name: str @@ -18,29 +18,37 @@ class ToolRecipe: @dataclass(frozen=True) -class Recipe: - tool: ToolRecipe +class BuildRecipe: + tool: ToolBuildRecipe sdk_git_ref: str unsigned_sha256: str -def load_recipe(path: Path) -> Recipe: - document = _load_json_object(path, "recipe") +def load_build_recipe(path: Path) -> BuildRecipe: + document = load_json_object(path, "build_recipe") tool = document.get("tool") artifact = document.get("artifact") sdk_git_ref = document.get("sdkGitRef") if not isinstance(tool, dict) or not isinstance(artifact, dict) or not isinstance(sdk_git_ref, str): raise SignerError("invalid_recipe", "recipe requires tool, artifact, and sdkGitRef") - values = (tool.get("id"), tool.get("versionCode"), tool.get("versionName"), tool.get("gitUrl"), tool.get("gitCommit")) - if not isinstance(values[0], str) or not isinstance(values[1], int) or isinstance(values[1], bool): + tool_id = tool.get("id") + version_code = tool.get("versionCode") + version_name = tool.get("versionName") + git_url = tool.get("gitUrl") + git_commit = tool.get("gitCommit") + if not isinstance(tool_id, str) or not isinstance(version_code, int) or isinstance(version_code, bool): raise SignerError("invalid_recipe", "recipe tool id/versionCode are invalid") - if not all(isinstance(value, str) for value in values[2:]): + if not isinstance(version_name, str) or not isinstance(git_url, str) or not isinstance(git_commit, str): raise SignerError("invalid_recipe", "recipe tool strings are invalid") digest = artifact.get("sha256") if not isinstance(digest, str) or len(digest) != 64: raise SignerError("invalid_recipe", "recipe artifact.sha256 is invalid") - validate_tool_id(values[0]) - return Recipe(ToolRecipe(*values), sdk_git_ref, digest.lower()) + validate_tool_id(tool_id) + return BuildRecipe( + ToolBuildRecipe(tool_id, version_code, version_name, git_url, git_commit), + sdk_git_ref, + digest.lower(), + ) def sha256(path: Path) -> str: diff --git a/signer/lightsigner/keys.py b/signer/lightsigner/keys.py index e86e32752..0d56f5951 100644 --- a/signer/lightsigner/keys.py +++ b/signer/lightsigner/keys.py @@ -8,7 +8,6 @@ from .registry import validate_tool_id from .tools import run_tool - KEY_ALIAS = "light-tool" PASSWORD_ENV = "LIGHT_SIGNER_KEY_PASSWORD" @@ -34,6 +33,8 @@ def generate_key(tool_id: str, keys_dir: Path, keytool: Path) -> str: certificate = certificate_path(keys_dir, tool_id) if directory.exists(): raise SignerError("key_exists", f"key already exists for {tool_id}") + # Octal maps each Unix owner/group/other permission triplet to one digit. + # Owner-only access: this directory contains the APK private-key keystore. directory.mkdir(parents=True, mode=0o700) try: _ = run_tool( @@ -78,7 +79,9 @@ def generate_key(tool_id: str, keys_dir: Path, keytool: Path) -> str: str(certificate), ] ) + # Owner read/write only: the PKCS#12 file contains the APK private key. os.chmod(keystore, 0o600) + # Publicly readable, owner writable: the DER certificate contains no secret. os.chmod(certificate, 0o644) return certificate_sha256(certificate) except Exception: diff --git a/signer/lightsigner/registry.py b/signer/lightsigner/registry.py index 0b9f08421..8002f2cef 100644 --- a/signer/lightsigner/registry.py +++ b/signer/lightsigner/registry.py @@ -8,9 +8,12 @@ from .errors import SignerError - TOOL_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") +# This local JSON registry is only the PoC substitute for the developer portal. +# Production will use an authenticated portal/database as the ownership authority +# and store KMS/HSM key identifiers rather than filesystem private-key locations. + @dataclass(frozen=True) class RegistryEntry: @@ -25,7 +28,7 @@ def validate_tool_id(tool_id: str) -> str: def load_registry(path: Path) -> dict[str, RegistryEntry]: - raw = _load_json_object(path, "registry") + raw = load_json_object(path, "registry") registry: dict[str, RegistryEntry] = {} for tool_id, value in raw.items(): validate_tool_id(tool_id) @@ -54,7 +57,7 @@ def require_owner( return entry -def _load_json_object(path: Path, label: str) -> dict[str, Any]: +def load_json_object(path: Path, label: str) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique_object) except (OSError, UnicodeError, json.JSONDecodeError) as error: diff --git a/signer/lightsigner/signing.py b/signer/lightsigner/signing.py index 844d0ceae..b96f69e67 100644 --- a/signer/lightsigner/signing.py +++ b/signer/lightsigner/signing.py @@ -3,25 +3,25 @@ import json from pathlib import Path -from .apk import verify_apk_signature +from .apk import read_trust_statement, verify_apk_signature +from .build_recipe import load_build_recipe, sha256 from .errors import SignerError from .keys import KEY_ALIAS, PASSWORD_ENV, keystore_path, require_password -from .recipe import load_recipe, sha256 from .registry import load_registry -from .stamp import read_statement from .tools import run_tool + # sign apk using android sdk apksigner -def sign_apk(*, apk: Path, recipe_path: Path, registry_path: Path, build_id: str, +def sign_apk(*, apk: Path, build_recipe_path: Path, registry_path: Path, build_id: str, keys_dir: Path, output: Path, metadata_output: Path, apksigner: Path) -> dict[str, object]: if apk.resolve() == output.resolve(): raise SignerError("output_overwrites_input", "output must differ from input") require_password() - recipe = load_recipe(recipe_path) - entry = load_registry(registry_path).get(recipe.tool.id) + build_recipe = load_build_recipe(build_recipe_path) + entry = load_registry(registry_path).get(build_recipe.tool.id) if entry is None: - raise SignerError("unregistered_tool", f"tool is not registered: {recipe.tool.id}") + raise SignerError("unregistered_tool", f"tool is not registered: {build_recipe.tool.id}") output.parent.mkdir(parents=True, exist_ok=True) run_tool([ @@ -31,13 +31,13 @@ def sign_apk(*, apk: Path, recipe_path: Path, registry_path: Path, build_id: str ]) signer_hash = verify_apk_signature(output, apksigner) - if read_statement(output).get("signerSha256") != signer_hash: + if read_trust_statement(output).get("signerSha256") != signer_hash: output.unlink(missing_ok=True) - raise SignerError("signer_mismatch", "statement signer does not match APK signing key") + raise SignerError("signer_mismatch", "trust statement signer does not match APK signing key") metadata: dict[str, object] = { "buildId": build_id, - "unsignedSha256": recipe.unsigned_sha256, + "unsignedSha256": build_recipe.unsigned_sha256, "apkSha256": sha256(output), "signerSha256": signer_hash, } diff --git a/signer/lightsigner/stamp.py b/signer/lightsigner/stamp.py index 2952516a2..6f3f7011d 100644 --- a/signer/lightsigner/stamp.py +++ b/signer/lightsigner/stamp.py @@ -7,80 +7,72 @@ from datetime import UTC, datetime from pathlib import Path -from .apk import inspect_apk +from .apk import TRUST_STATEMENT_PATH, ApkMetadata, inspect_apk +from .build_recipe import BuildRecipe, load_build_recipe, sha256 from .errors import SignerError from .keys import certificate_path, certificate_sha256 -from .recipe import Recipe, load_recipe, sha256 from .registry import load_registry, require_owner -from .statement import canonical_bytes, sign_ed25519 +from .trust_statement import canonical_bytes, sign_ed25519 - -STATEMENT_PATH = "META-INF/light-trust.json" ATTESTATION_KEY_ID = "light-attest-1" -def stamp_apk(*, apk: Path, recipe_path: Path, registry_path: Path, dev_id: str, +def stamp_apk(*, apk: Path, build_recipe_path: Path, registry_path: Path, dev_id: str, build_id: str, attestation_key: Path, keys_dir: Path, output: Path, apkanalyzer: Path, issued_at: str | None = None) -> dict[str, object]: # verify that unsigned apk matches build recipe and stamp parameters - recipe = load_recipe(recipe_path) + build_recipe = load_build_recipe(build_recipe_path) actual_hash = sha256(apk) - if actual_hash != recipe.unsigned_sha256: + if actual_hash != build_recipe.unsigned_sha256: raise SignerError("unsigned_hash_mismatch", "APK SHA-256 does not match recipe") - _check_metadata(recipe, inspect_apk(apk, apkanalyzer)) - entry = require_owner(load_registry(registry_path), recipe.tool.id, dev_id) + _check_metadata(build_recipe, inspect_apk(apk, apkanalyzer)) + entry = require_owner(load_registry(registry_path), build_recipe.tool.id, dev_id) # build and sign trust statement - statement: dict[str, object] = { + trust_statement: dict[str, object] = { "schemaVersion": 1, "tool": { - "id": recipe.tool.id, - "versionCode": recipe.tool.version_code, - "versionName": recipe.tool.version_name, - "gitUrl": recipe.tool.git_url, - "gitCommit": recipe.tool.git_commit, + "id": build_recipe.tool.id, + "versionCode": build_recipe.tool.version_code, + "versionName": build_recipe.tool.version_name, + "gitUrl": build_recipe.tool.git_url, + "gitCommit": build_recipe.tool.git_commit, }, - "sdkGitRef": recipe.sdk_git_ref, + "sdkGitRef": build_recipe.sdk_git_ref, "devId": dev_id, "signerSha256": certificate_sha256(certificate_path(keys_dir, entry.key_id)), "buildId": build_id, "unsignedSha256": actual_hash, "issuedAt": issued_at or datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"), } - signature = sign_ed25519(canonical_bytes(statement), attestation_key) - statement["attestation"] = { + signature = sign_ed25519(canonical_bytes(trust_statement), attestation_key) + trust_statement["attestation"] = { "keyId": ATTESTATION_KEY_ID, "alg": "Ed25519", "sig": base64.b64encode(signature).decode("ascii"), } # inject trust statement in build - _rewrite_zip(apk, output, json.dumps(statement, indent=2, sort_keys=True).encode() + b"\n") - return statement - - -def read_statement(apk: Path) -> dict[str, object]: - try: - with zipfile.ZipFile(apk) as archive: - matches = [info for info in archive.infolist() if info.filename == STATEMENT_PATH] - if len(matches) != 1: - raise SignerError("invalid_statement_count", f"expected one trust statement, found {len(matches)}") - value = json.loads(archive.read(matches[0]), object_pairs_hook=_unique_object) - except (OSError, zipfile.BadZipFile, json.JSONDecodeError, UnicodeError) as error: - raise SignerError("invalid_statement", "trust statement is unreadable") from error - if not isinstance(value, dict): - raise SignerError("invalid_statement", "trust statement must be an object") - return value - - -def _check_metadata(recipe: Recipe, metadata: object) -> None: - expected = (recipe.tool.id, recipe.tool.version_code, recipe.tool.version_name) + _rewrite_zip( + apk, + output, + json.dumps(trust_statement, indent=2, sort_keys=True).encode() + b"\n", + ) + return trust_statement + + +def _check_metadata(build_recipe: BuildRecipe, metadata: ApkMetadata) -> None: + expected = ( + build_recipe.tool.id, + build_recipe.tool.version_code, + build_recipe.tool.version_name, + ) actual = (metadata.application_id, metadata.version_code, metadata.version_name) if actual != expected: raise SignerError("apk_metadata_mismatch", f"APK metadata {actual!r} does not match recipe {expected!r}") -def _rewrite_zip(source: Path, output: Path, statement: bytes) -> None: +def _rewrite_zip(source: Path, output: Path, trust_statement_bytes: bytes) -> None: if source.resolve() == output.resolve(): raise SignerError("output_overwrites_input", "output must differ from input") output.parent.mkdir(parents=True, exist_ok=True) @@ -89,19 +81,14 @@ def _rewrite_zip(source: Path, output: Path, statement: bytes) -> None: try: with zipfile.ZipFile(source) as original, zipfile.ZipFile(temporary_path, "w") as rewritten: for info in original.infolist(): - if info.filename != STATEMENT_PATH: + if info.filename != TRUST_STATEMENT_PATH: rewritten.writestr(info, original.read(info)) - rewritten.writestr(STATEMENT_PATH, statement, compress_type=zipfile.ZIP_DEFLATED) + rewritten.writestr( + TRUST_STATEMENT_PATH, + trust_statement_bytes, + compress_type=zipfile.ZIP_DEFLATED, + ) _ = temporary_path.replace(output) except Exception: temporary_path.unlink(missing_ok=True) raise - - -def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: - result: dict[str, object] = {} - for key, value in pairs: - if key in result: - raise SignerError("invalid_statement", f"duplicate field: {key}") - result[key] = value - return result diff --git a/signer/lightsigner/tools.py b/signer/lightsigner/tools.py index 0c98b7d8d..9fb24ff51 100644 --- a/signer/lightsigner/tools.py +++ b/signer/lightsigner/tools.py @@ -34,8 +34,7 @@ def resolve_tool(name: str, explicit: Path | None = None) -> Path: def run_tool(command: list[str], *, env: dict[str, str] | None = None) -> str: result = subprocess.run( command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, env=env, check=False, diff --git a/signer/lightsigner/statement.py b/signer/lightsigner/trust_statement.py similarity index 91% rename from signer/lightsigner/statement.py rename to signer/lightsigner/trust_statement.py index 334e4bb0f..2521bb1ab 100644 --- a/signer/lightsigner/statement.py +++ b/signer/lightsigner/trust_statement.py @@ -25,7 +25,7 @@ def canonical_bytes(document: Mapping[str, Any]) -> bytes: def _reject_floats(value: Any) -> None: if isinstance(value, float): - raise ValueError("floats are not allowed in trust documents") + raise TypeError("floats are not allowed in trust documents") if isinstance(value, Mapping): for child in value.values(): _reject_floats(child) @@ -49,8 +49,7 @@ def sign_ed25519(payload: bytes, private_key: Path) -> bytes: "-in", payload_file.name, ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, check=False, ) if result.returncode != 0: @@ -78,8 +77,7 @@ def verify_ed25519(payload: bytes, signature: bytes, public_key: Path) -> bool: "-sigfile", signature_file.name, ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, check=False, ) return result.returncode == 0 diff --git a/signer/lightsigner/verify.py b/signer/lightsigner/verify.py index c9de5067b..d862db133 100644 --- a/signer/lightsigner/verify.py +++ b/signer/lightsigner/verify.py @@ -4,17 +4,17 @@ import binascii from pathlib import Path -from .apk import inspect_apk, verify_apk_signature +from .apk import inspect_apk, read_trust_statement, verify_apk_signature from .errors import SignerError -from .stamp import ATTESTATION_KEY_ID, read_statement -from .statement import canonical_bytes, verify_ed25519 +from .stamp import ATTESTATION_KEY_ID +from .trust_statement import canonical_bytes, verify_ed25519 def verify_apk(*, apk: Path, attestation_public_key: Path, apksigner: Path, apkanalyzer: Path, expected_key_id: str = ATTESTATION_KEY_ID) -> dict[str, object]: signer_hash = verify_apk_signature(apk, apksigner) - statement = read_statement(apk) - attestation = statement.get("attestation") + trust_statement = read_trust_statement(apk) + attestation = trust_statement.get("attestation") if not isinstance(attestation, dict): raise SignerError("invalid_attestation", "attestation is missing") @@ -32,21 +32,21 @@ def verify_apk(*, apk: Path, attestation_public_key: Path, apksigner: Path, raise SignerError("invalid_attestation", "attestation signature is not base64") from error try: - verified = verify_ed25519(canonical_bytes(statement), signature, attestation_public_key) - except ValueError as error: + verified = verify_ed25519(canonical_bytes(trust_statement), signature, attestation_public_key) + except TypeError as error: raise SignerError("invalid_statement", str(error)) from error if not verified: raise SignerError("attestation_failed", "trust statement signature is invalid") - if statement.get("signerSha256") != signer_hash: - raise SignerError("signer_mismatch", "statement signer does not match APK signer") + if trust_statement.get("signerSha256") != signer_hash: + raise SignerError("signer_mismatch", "trust statement signer does not match APK signer") - tool = statement.get("tool") + tool = trust_statement.get("tool") if not isinstance(tool, dict): - raise SignerError("invalid_statement", "statement tool is missing") + raise SignerError("invalid_statement", "trust statement tool is missing") metadata = inspect_apk(apk, apkanalyzer) if (tool.get("id"), tool.get("versionCode"), tool.get("versionName")) != ( metadata.application_id, metadata.version_code, metadata.version_name ): raise SignerError("statement_apk_mismatch", "statement tool metadata does not match APK") - return statement + return trust_statement diff --git a/signer/tests/conftest.py b/signer/tests/conftest.py index 6a2c99839..e9c8399b3 100644 --- a/signer/tests/conftest.py +++ b/signer/tests/conftest.py @@ -3,7 +3,6 @@ import sys from pathlib import Path - SIGNER_ROOT = Path(__file__).resolve().parent.parent if str(SIGNER_ROOT) not in sys.path: sys.path.insert(0, str(SIGNER_ROOT)) diff --git a/signer/tests/test_android_integration.py b/signer/tests/test_android_integration.py index 9a34f2106..e5b9c9208 100644 --- a/signer/tests/test_android_integration.py +++ b/signer/tests/test_android_integration.py @@ -7,15 +7,15 @@ from pathlib import Path import pytest - -from lightsigner.keys import generate_key +from lightsigner.build_recipe import sha256 from lightsigner.errors import SignerError -from lightsigner.recipe import sha256 +from lightsigner.keys import generate_key from lightsigner.signing import sign_apk from lightsigner.stamp import stamp_apk from lightsigner.tools import resolve_tool, run_tool from lightsigner.verify import verify_apk + # Runs the complete workflow using real JDK and Android tools. ## In the future this can be extended to do the full build -> stamp -> sign -> verify flow @pytest.mark.skipif(os.environ.get("LIGHT_SIGNER_ANDROID_INTEGRATION") != "1", reason="opt-in Android integration") @@ -54,8 +54,8 @@ def test_real_android_signing_round_trip(tmp_path, monkeypatch) -> None: # Stamp the apk with a trust statement ## we'll need to mock the build recipe - recipe = tmp_path / "recipe.json" - recipe.write_text(json.dumps({ + build_recipe = tmp_path / "recipe.json" + build_recipe.write_text(json.dumps({ "schemaVersion": 1, "artifact": {"sha256": sha256(unsigned)}, "tool": {"id": tool_id, "versionCode": 1, "versionName": "1.0.0", "gitUrl": "https://example.test/tool", "gitCommit": "a" * 40}, @@ -67,19 +67,19 @@ def test_real_android_signing_round_trip(tmp_path, monkeypatch) -> None: stamped = tmp_path / "stamped.apk" signed = tmp_path / "signed.apk" signed_metadata = tmp_path / "signed.json" - stamp_apk(apk=unsigned, recipe_path=recipe, registry_path=registry, dev_id="dev_integration", + stamp_apk(apk=unsigned, build_recipe_path=build_recipe, registry_path=registry, dev_id="dev_integration", build_id="build_integration", attestation_key=private_key, keys_dir=keys_dir, output=stamped, apkanalyzer=apkanalyzer) # Sign apk after stamped - result = sign_apk(apk=stamped, recipe_path=recipe, registry_path=registry, + result = sign_apk(apk=stamped, build_recipe_path=build_recipe, registry_path=registry, build_id="build_integration", keys_dir=keys_dir, output=signed, metadata_output=signed_metadata, apksigner=apksigner) # Verify stamped and signed apk - statement = verify_apk(apk=signed, attestation_public_key=public_key, + trust_statement = verify_apk(apk=signed, attestation_public_key=public_key, apksigner=apksigner, apkanalyzer=apkanalyzer) - assert statement["signerSha256"] == result["signerSha256"] + assert trust_statement["signerSha256"] == result["signerSha256"] assert result["apkSha256"] == sha256(signed) foreign_private = tmp_path / "foreign-private.pem" diff --git a/signer/tests/test_registry.py b/signer/tests/test_registry.py index 7b9168e49..5546d2116 100644 --- a/signer/tests/test_registry.py +++ b/signer/tests/test_registry.py @@ -3,7 +3,6 @@ import json import pytest - from lightsigner.errors import SignerError from lightsigner.registry import load_registry, require_owner diff --git a/signer/tests/test_stamp.py b/signer/tests/test_stamp.py index 9e516201e..24c362288 100644 --- a/signer/tests/test_stamp.py +++ b/signer/tests/test_stamp.py @@ -5,10 +5,9 @@ import zipfile import pytest - -from lightsigner.apk import ApkMetadata +from lightsigner.apk import TRUST_STATEMENT_PATH, ApkMetadata, read_trust_statement from lightsigner.errors import SignerError -from lightsigner.stamp import STATEMENT_PATH, read_statement, stamp_apk +from lightsigner.stamp import stamp_apk def _inputs(tmp_path): @@ -17,8 +16,8 @@ def _inputs(tmp_path): archive.writestr("classes.dex", b"dex", compress_type=zipfile.ZIP_STORED) archive.writestr("assets/data", b"payload", compress_type=zipfile.ZIP_DEFLATED) digest = hashlib.sha256(apk.read_bytes()).hexdigest() - recipe = tmp_path / "recipe.json" - recipe.write_text(json.dumps({ + build_recipe = tmp_path / "recipe.json" + build_recipe.write_text(json.dumps({ "schemaVersion": 1, "artifact": {"sha256": digest}, "tool": {"id": "com.example.tool", "versionCode": 3, "versionName": "1.2", "gitUrl": "https://example.test/tool", "gitCommit": "a" * 40}, @@ -29,31 +28,31 @@ def _inputs(tmp_path): keys = tmp_path / "keys" / "com.example.tool" keys.mkdir(parents=True) (keys / "certificate.der").write_bytes(b"certificate") - return apk, recipe, registry + return apk, build_recipe, registry def test_stamp_preserves_entries_and_attests(tmp_path, monkeypatch) -> None: - apk, recipe, registry = _inputs(tmp_path) + apk, build_recipe, registry = _inputs(tmp_path) output = tmp_path / "stamped.apk" monkeypatch.setattr("lightsigner.stamp.inspect_apk", lambda *_: ApkMetadata("com.example.tool", 3, "1.2")) monkeypatch.setattr("lightsigner.stamp.sign_ed25519", lambda *_: b"signature") - stamp_apk(apk=apk, recipe_path=recipe, registry_path=registry, dev_id="dev_1", build_id="build_1", + stamp_apk(apk=apk, build_recipe_path=build_recipe, registry_path=registry, dev_id="dev_1", build_id="build_1", attestation_key=tmp_path / "unused", keys_dir=tmp_path / "keys", output=output, apkanalyzer=tmp_path / "unused", issued_at="2026-08-25T00:00:00Z") with zipfile.ZipFile(apk) as before, zipfile.ZipFile(output) as after: for name in ("classes.dex", "assets/data"): assert after.read(name) == before.read(name) assert after.getinfo(name).compress_type == before.getinfo(name).compress_type - assert [info.filename for info in after.infolist()].count(STATEMENT_PATH) == 1 - assert read_statement(output)["unsignedSha256"] == hashlib.sha256(apk.read_bytes()).hexdigest() + assert [info.filename for info in after.infolist()].count(TRUST_STATEMENT_PATH) == 1 + assert read_trust_statement(output)["unsignedSha256"] == hashlib.sha256(apk.read_bytes()).hexdigest() def test_stamp_checks_apk_metadata_before_registry(tmp_path, monkeypatch) -> None: - apk, recipe, registry = _inputs(tmp_path) + apk, build_recipe, registry = _inputs(tmp_path) registry.write_text("{}") monkeypatch.setattr("lightsigner.stamp.inspect_apk", lambda *_: ApkMetadata("com.other.tool", 3, "1.2")) with pytest.raises(SignerError) as failure: - stamp_apk(apk=apk, recipe_path=recipe, registry_path=registry, dev_id="dev_1", build_id="build_1", + stamp_apk(apk=apk, build_recipe_path=build_recipe, registry_path=registry, dev_id="dev_1", build_id="build_1", attestation_key=tmp_path / "unused", keys_dir=tmp_path / "keys", output=tmp_path / "out.apk", apkanalyzer=tmp_path / "unused") assert failure.value.code == "apk_metadata_mismatch" diff --git a/signer/tests/test_statement.py b/signer/tests/test_statement.py deleted file mode 100644 index d9c3c7085..000000000 --- a/signer/tests/test_statement.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import base64 -import json -from pathlib import Path - -from lightsigner.statement import canonical_bytes, verify_ed25519 - - -VECTORS = Path(__file__).parent / "vectors" - - -def _statement() -> dict[str, object]: - return json.loads((VECTORS / "statement.json").read_text(encoding="utf-8")) - - -def test_canonical_bytes_match_vector() -> None: - assert canonical_bytes(_statement()) == (VECTORS / "statement.canonical.json").read_bytes() - - -def test_reordered_keys_canonicalize_identically() -> None: - statement = _statement() - reordered = dict(reversed(list(statement.items()))) - assert canonical_bytes(reordered) == canonical_bytes(statement) - - -def test_unicode_is_utf8_not_ascii_escaped() -> None: - canonical = canonical_bytes(_statement()) - assert "luz-☀".encode() in canonical - assert b"\\u2600" not in canonical - - -def test_vector_signature_verifies() -> None: - statement = _statement() - signature = base64.b64decode(statement["attestation"]["sig"], validate=True) - assert verify_ed25519( - canonical_bytes(statement), signature, VECTORS / "test-attestation-public.pem" - ) - - -def test_modified_statement_does_not_verify() -> None: - statement = _statement() - signature = base64.b64decode(statement["attestation"]["sig"], validate=True) - statement["tool"]["versionCode"] = 4 - assert not verify_ed25519( - canonical_bytes(statement), signature, VECTORS / "test-attestation-public.pem" - ) - - -def test_floats_are_rejected() -> None: - statement = _statement() - statement["schemaVersion"] = 1.0 - try: - canonical_bytes(statement) - except ValueError as error: - assert str(error) == "floats are not allowed in trust documents" - else: - raise AssertionError("float was accepted") diff --git a/signer/tests/test_trust_statement.py b/signer/tests/test_trust_statement.py new file mode 100644 index 000000000..4b9cec185 --- /dev/null +++ b/signer/tests/test_trust_statement.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +from lightsigner.trust_statement import canonical_bytes, verify_ed25519 + +VECTORS = Path(__file__).parent / "vectors" + + +def _trust_statement() -> dict[str, object]: + return json.loads((VECTORS / "statement.json").read_text(encoding="utf-8")) + + +def test_canonical_bytes_match_vector() -> None: + assert canonical_bytes(_trust_statement()) == (VECTORS / "statement.canonical.json").read_bytes() + + +def test_reordered_keys_canonicalize_identically() -> None: + trust_statement = _trust_statement() + reordered = dict(reversed(list(trust_statement.items()))) + assert canonical_bytes(reordered) == canonical_bytes(trust_statement) + + +def test_unicode_is_utf8_not_ascii_escaped() -> None: + canonical = canonical_bytes(_trust_statement()) + assert "luz-☀".encode() in canonical + assert b"\\u2600" not in canonical + + +def test_vector_signature_verifies() -> None: + trust_statement = _trust_statement() + signature = base64.b64decode(trust_statement["attestation"]["sig"], validate=True) + assert verify_ed25519( + canonical_bytes(trust_statement), signature, VECTORS / "test-attestation-public.pem" + ) + + +def test_modified_trust_statement_does_not_verify() -> None: + trust_statement = _trust_statement() + signature = base64.b64decode(trust_statement["attestation"]["sig"], validate=True) + trust_statement["tool"]["versionCode"] = 4 + assert not verify_ed25519( + canonical_bytes(trust_statement), signature, VECTORS / "test-attestation-public.pem" + ) + + +def test_floats_are_rejected() -> None: + trust_statement = _trust_statement() + trust_statement["schemaVersion"] = 1.0 + try: + canonical_bytes(trust_statement) + except TypeError as error: + assert str(error) == "floats are not allowed in trust documents" + else: + raise AssertionError("float was accepted") diff --git a/signer/tests/test_verify.py b/signer/tests/test_verify.py index 8d1fea4fa..fc505494c 100644 --- a/signer/tests/test_verify.py +++ b/signer/tests/test_verify.py @@ -6,39 +6,36 @@ from pathlib import Path import pytest - -from lightsigner.apk import ApkMetadata +from lightsigner.apk import TRUST_STATEMENT_PATH, ApkMetadata, read_trust_statement from lightsigner.errors import SignerError -from lightsigner.stamp import STATEMENT_PATH, read_statement from lightsigner.verify import verify_apk - VECTORS = Path(__file__).parent / "vectors" -def _apk(tmp_path, statement=None, copies=1): +def _apk(tmp_path, trust_statement=None, copies=1): apk = tmp_path / "tool.apk" with zipfile.ZipFile(apk, "w") as archive: archive.writestr("classes.dex", b"dex") for _ in range(copies): - if statement is not None: + if trust_statement is not None: with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - archive.writestr(STATEMENT_PATH, json.dumps(statement)) + archive.writestr(TRUST_STATEMENT_PATH, json.dumps(trust_statement)) return apk -@pytest.mark.parametrize(("statement", "copies"), [(None, 1), ({}, 2)]) -def test_read_statement_requires_exactly_one(tmp_path, statement, copies) -> None: +@pytest.mark.parametrize(("trust_statement", "copies"), [(None, 1), ({}, 2)]) +def test_read_trust_statement_requires_exactly_one(tmp_path, trust_statement, copies) -> None: with pytest.raises(SignerError) as failure: - read_statement(_apk(tmp_path, statement, copies)) + read_trust_statement(_apk(tmp_path, trust_statement, copies)) assert failure.value.code == "invalid_statement_count" -def test_verify_rejects_edited_statement(tmp_path, monkeypatch) -> None: - statement = json.loads((VECTORS / "statement.json").read_text()) - statement["buildId"] = "edited" - apk = _apk(tmp_path, statement) +def test_verify_rejects_edited_trust_statement(tmp_path, monkeypatch) -> None: + trust_statement = json.loads((VECTORS / "statement.json").read_text()) + trust_statement["buildId"] = "edited" + apk = _apk(tmp_path, trust_statement) monkeypatch.setattr("lightsigner.verify.verify_apk_signature", lambda *_: "c" * 64) monkeypatch.setattr("lightsigner.verify.inspect_apk", lambda *_: ApkMetadata("com.example.tool", 3, "1.2.0")) with pytest.raises(SignerError) as failure: @@ -48,9 +45,9 @@ def test_verify_rejects_edited_statement(tmp_path, monkeypatch) -> None: assert failure.value.code == "attestation_failed" -def test_verify_rejects_statement_transplanted_to_another_apk(tmp_path, monkeypatch) -> None: - statement = json.loads((VECTORS / "statement.json").read_text()) - apk = _apk(tmp_path, statement) +def test_verify_rejects_trust_statement_transplanted_to_another_apk(tmp_path, monkeypatch) -> None: + trust_statement = json.loads((VECTORS / "statement.json").read_text()) + apk = _apk(tmp_path, trust_statement) monkeypatch.setattr("lightsigner.verify.verify_apk_signature", lambda *_: "c" * 64) monkeypatch.setattr("lightsigner.verify.inspect_apk", lambda *_: ApkMetadata("com.other.tool", 3, "1.2.0")) with pytest.raises(SignerError) as failure: diff --git a/signer/tests/vectors/generate.py b/signer/tests/vectors/generate.py index e5e2e68da..4046bce2a 100644 --- a/signer/tests/vectors/generate.py +++ b/signer/tests/vectors/generate.py @@ -6,12 +6,12 @@ import json from pathlib import Path -from lightsigner.statement import canonical_bytes, sign_ed25519 +from lightsigner.trust_statement import canonical_bytes, sign_ed25519 VECTORS = Path(__file__).resolve().parent -statement: dict[str, object] = { +trust_statement: dict[str, object] = { "schemaVersion": 1, "tool": { "id": "com.example.tool", @@ -27,9 +27,9 @@ "unsignedSha256": "d" * 64, "issuedAt": "2026-08-25T00:00:00Z", } -canonical = canonical_bytes(statement) +canonical = canonical_bytes(trust_statement) signature = sign_ed25519(canonical, VECTORS / "test-attestation-private.pem") -statement["attestation"] = { +trust_statement["attestation"] = { "keyId": "test-attestation-1", "alg": "Ed25519", "sig": base64.b64encode(signature).decode("ascii"), @@ -37,5 +37,5 @@ (VECTORS / "statement.canonical.json").write_bytes(canonical) (VECTORS / "statement.json").write_text( - json.dumps(statement, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + json.dumps(trust_statement, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" )