Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions src/main/java/com/aws/greengrass/DeviceIdentityHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.security.GeneralSecurityException;
Expand All @@ -22,18 +21,59 @@
import java.util.Scanner;

public class DeviceIdentityHelper {
private final Logger logger = LogManager.getLogger(MqttConnectionHelper.class);
private final Logger logger = LogManager.getLogger(DeviceIdentityHelper.class);

/**
* Resolve the device UUID by running `uuid` on PATH. Each platform supplies its
* own: the Yocto image installs it via the fb-uuid recipe, the factory container
* ships a shim that reads /data/.device-uuid. The indirection is deliberate —
* the plugin must not know any platform's on-disk layout.
*
* <p>Failures are fatal and must say so. This previously returned null on any
* error, which surfaced downstream as an opaque NullPointerException in sign()
* while the command's own diagnosis was discarded with its stderr.
*
* @return the device UUID, never null or empty
* @throws DeviceProvisioningRuntimeException if `uuid` is missing, exits non-zero,
* or prints nothing
*/
String getClientId() {
String result = null;
String[] cmd = { "uuid" };
try (InputStream inputStream = Runtime.getRuntime().exec(cmd).getInputStream();
Scanner s = new Scanner(inputStream).useDelimiter("\\A")) {
result = s.hasNext() ? s.next().replaceAll(System.lineSeparator(), "") : null;
return getClientId(new String[] { "uuid" });
}

// Visible for testing: lets a test point at a stub instead of relying on PATH.
String getClientId(String[] cmd) {
try {
Process process = Runtime.getRuntime().exec(cmd);

String stdout;
String stderr;
try (Scanner out = new Scanner(process.getInputStream()).useDelimiter("\\A");
Scanner err = new Scanner(process.getErrorStream()).useDelimiter("\\A")) {
stdout = out.hasNext() ? out.next().trim() : "";
stderr = err.hasNext() ? err.next().trim() : "";
}

int exitCode = process.waitFor();
if (exitCode != 0) {
throw new DeviceProvisioningRuntimeException(
"`uuid` exited " + exitCode + ", cannot determine device identity"
+ (stderr.isEmpty() ? "" : ": " + stderr));
}
if (stdout.isEmpty()) {
throw new DeviceProvisioningRuntimeException(
"`uuid` printed nothing, cannot determine device identity");
}

logger.atDebug().kv("uuid", stdout).log("Resolved device identity");
return stdout;
} catch (IOException e) {
e.printStackTrace();
throw new DeviceProvisioningRuntimeException(
"Failed to run `uuid` — is it on PATH?", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new DeviceProvisioningRuntimeException("Interrupted while running `uuid`", e);
}
return result;
}

PrivateKey readPrivateKey(File file) throws GeneralSecurityException, IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,13 @@ public ProvisionConfiguration updateIdentityConfiguration(ProvisionContext provi
}
}

// Resolve identity before signing: a failure here means "we don't know
// who this device is", which is not a signing problem and must not be
// reported as one.
clientId = this.deviceIdentityHelper.getClientId();

// Sign the clientId with the private key
try {
clientId = this.deviceIdentityHelper.getClientId();

if (useTpmProvisioning) {
signature = pkcsProviderInstance.sign(clientId, SIGN_KEY_LABEL);
} else {
Expand Down
86 changes: 86 additions & 0 deletions src/test/java/com/aws/greengrass/DeviceIdentityHelperTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* Modifications Copyright 2022-2026 Factbird ApS. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package com.aws.greengrass;

import com.aws.greengrass.testcommons.testutilities.GGExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@ExtendWith({ GGExtension.class })
class DeviceIdentityHelperTest {

private final DeviceIdentityHelper helper = new DeviceIdentityHelper();

private String[] stubUuid(Path dir, String body) throws IOException {
Path script = dir.resolve("uuid-stub.sh");
Files.write(script, ("#!/bin/sh\n" + body + "\n").getBytes(StandardCharsets.UTF_8));
Files.setPosixFilePermissions(script,
Stream.of("OWNER_READ", "OWNER_WRITE", "OWNER_EXECUTE")
.map(java.nio.file.attribute.PosixFilePermission::valueOf)
.collect(Collectors.toCollection(java.util.LinkedHashSet::new)));
return new String[] { script.toString() };
}

@Test
void GIVEN_uuid_prints_identity_WHEN_getClientId_THEN_returns_it(@TempDir Path dir) throws IOException {
String[] cmd = stubUuid(dir, "echo cb7bc3095fc54c7c83bfbb0e4414afe2");
assertEquals("cb7bc3095fc54c7c83bfbb0e4414afe2", helper.getClientId(cmd));
}

@Test
void GIVEN_uuid_exits_nonzero_WHEN_getClientId_THEN_throws_with_stderr(@TempDir Path dir) throws IOException {
// Mirrors the container shim's behaviour when /data/.device-uuid is absent.
String[] cmd = stubUuid(dir, "echo 'uuid: /data/.device-uuid missing' >&2; exit 1");

DeviceProvisioningRuntimeException ex = assertThrows(DeviceProvisioningRuntimeException.class,
() -> helper.getClientId(cmd));

assertTrue(ex.getMessage().contains("exited 1"), ex.getMessage());
// The command's own diagnosis must survive — discarding it is what made the
// original failure surface as an opaque NullPointerException in sign().
assertTrue(ex.getMessage().contains("/data/.device-uuid missing"), ex.getMessage());
}

@Test
void GIVEN_uuid_prints_nothing_WHEN_getClientId_THEN_throws(@TempDir Path dir) throws IOException {
String[] cmd = stubUuid(dir, "exit 0");

DeviceProvisioningRuntimeException ex = assertThrows(DeviceProvisioningRuntimeException.class,
() -> helper.getClientId(cmd));

assertTrue(ex.getMessage().contains("printed nothing"), ex.getMessage());
}

@Test
void GIVEN_uuid_not_on_path_WHEN_getClientId_THEN_throws(@TempDir Path dir) {
String[] cmd = { dir.resolve("does-not-exist").toString() };

DeviceProvisioningRuntimeException ex = assertThrows(DeviceProvisioningRuntimeException.class,
() -> helper.getClientId(cmd));

assertTrue(ex.getMessage().contains("PATH"), ex.getMessage());
}

@Test
void GIVEN_uuid_output_has_trailing_newline_WHEN_getClientId_THEN_trimmed(@TempDir Path dir) throws IOException {
String[] cmd = stubUuid(dir, "printf 'abc123\\n'");
assertEquals("abc123", helper.getClientId(cmd));
}
}
Loading