Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
36 changes: 26 additions & 10 deletions ddi/tbor/types/src/sd_sealing_key_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,34 @@

//! Host-side wrapper for the TBOR `SdSealingKeyGen` command.
//!
//! `SdSealingKeyGen` is an **in-session** command that generates a new
//! security-domain sealing key in the partition's vault and returns its
//! key handle.
//! `SdSealingKeyGen` is an **in-session** Crypto-Officer command that
//! generates a new security-domain sealing key (ECC-P384) and returns
//! the **masked** private key plus its public key. The private key is
//! not stored on the device; the caller holds the masked blob and
//! re-imports it (unmask-on-use) when the key is later needed.
//!
//! The request carries the requested key `scope` (lifecycle / visibility
//! domain) as its 1-byte discriminant. The firmware-side schema
//! (`azihsm_fw_ddi_tbor_types::sd_sealing_key_gen`) types it as the
//! `KeyScope` open-enum (mirror of the PAL `HsmKeyScope`); this host
//! crate is firewalled from the firmware PAL types, so it carries the
//! same byte as a raw `u8`.
//! same byte as a raw `u8`. The private key is masked under the masking
//! key associated with the scope.

use crate::tbor;

/// TBOR opcode for `SdSealingKeyGen`.
pub const TBOR_OP_SD_SEALING_KEY_GEN: u8 = 0x09;

/// Wire length of the returned sealing public key: a raw P-384 point
/// (`x ‖ y`, 48 + 48 bytes).
pub const SD_SEALING_PUB_KEY_LEN: usize = 96;

/// Wire length of the masked sealing private key: an AEAD-GCM-256
/// masked-key envelope (`header(8) ‖ iv(12) ‖ aad(96) ‖ pt(48) ‖
/// tag(16)`) over the 48-byte raw P-384 private scalar.
pub const MASKED_SEALING_KEY_LEN: usize = 8 + 12 + 96 + 48 + 16;

/// Host-facing TBOR `SdSealingKeyGen` request.
#[tbor(opcode = TBOR_OP_SD_SEALING_KEY_GEN, session_ctrl = in_session)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
Expand All @@ -35,13 +47,17 @@ pub struct TborSdSealingKeyGenReq {

/// Host-facing TBOR `SdSealingKeyGen` response.
#[tbor(response)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Comment thread
vsonims marked this conversation as resolved.
pub struct TborSdSealingKeyGenResp {
/// Vault id (`HsmKeyId`) of the newly generated sealing key. Carried
/// as a `KeyId` (inline 16-bit, TOC entry type 1); represented here
/// as the raw `u16`.
#[tbor(key_id)]
pub key_handle: u16,
/// The new sealing key's ECC-P384 private half, masked
/// (AEAD-GCM-256) under the requested scope's masking key. Always
/// exactly [`MASKED_SEALING_KEY_LEN`] (180 B); not stored on-device.
pub masked_key: [u8; MASKED_SEALING_KEY_LEN],

/// Raw P-384 public key (`x ‖ y` affine coordinates, 96 bytes,
/// little-endian per coordinate) of the new sealing key. Not a SEC1
/// point encoding (no `0x04` prefix).
pub pub_key: [u8; SD_SEALING_PUB_KEY_LEN],
}

#[cfg(test)]
Expand Down
5 changes: 5 additions & 0 deletions ddi/tbor/types/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ pub enum TborStatus {
UmsKeyAlreadySet = 0x087000FD,
UmsKeyNotSet = 0x087000FE,

/// A command was asked to operate on a `HsmKeyScope` whose backing
/// key material does not yet exist (mirror of
/// `HsmError::UnsupportedKeyScope`).
UnsupportedKeyScope = 0x08700102,

/// The SQE's out-of-band descriptor-array length (`oob_len`) is
/// malformed (mirror of `HsmError::IoChannelInvalidOobLen`).
IoChannelInvalidOobLen = 0x08700104,
Expand Down
1 change: 1 addition & 0 deletions ddi/tbor/types/tests/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ pub mod part_final;
pub mod part_info;
pub mod part_init;
pub mod psk_change;
pub mod sd_sealing_key_gen;
pub mod session_close;
pub mod unexpected_toc_type;
205 changes: 205 additions & 0 deletions ddi/tbor/types/tests/commands/sd_sealing_key_gen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! Integration tests for the TBOR `SdSealingKeyGen` command.
//!
//! Cross-test isolation comes from `open_dev`'s factory reset; no
//! per-test cleanup is required (see [`crate::harness::fixture`]).
//!
//! The command generates a P-384 sealing keypair and returns the
//! **masked** private key (masked under the requested scope's masking
//! key) plus the public key — nothing is stored on the device. The
//! Ephemeral/Local masking keys are provisioned by `PartFinal`, so the
//! happy-path tests first drive `PartInit → PartFinal`.
//!
//! Coverage:
//! * Happy path (Ephemeral + Local) — returns a non-zero 180-byte masked
//! key + 96-byte public key; a second call yields a distinct keypair.
//! * Unsupported scope (Session + SecurityDomain) → `UnsupportedKeyScope`.
//! * Before finalize (partition not `Initialized`) → `InvalidArg`.
//! * Crypto-User session → `InvalidPermissions`.
//! * Default-PSK gate → `DefaultPskMustRotate` (dispatcher, pre-handler).

#![cfg(feature = "emu")]

use azihsm_ddi_tbor_types::SessionType;
use azihsm_ddi_tbor_types::TborSdSealingKeyGenReq;
use azihsm_ddi_tbor_types::TborStatus;
use azihsm_ddi_tbor_types::MASKED_SEALING_KEY_LEN;
use azihsm_ddi_tbor_types::PSK_LEN;
use azihsm_ddi_tbor_types::SD_SEALING_PUB_KEY_LEN;

use crate::commands::part_init::bootstrap_rotated_co;
use crate::commands::part_init::mach_seed;
use crate::commands::part_init::part_policy_with_pota;
use crate::commands::part_init::pota_thumbprint;
use crate::commands::part_init::CO;
use crate::commands::part_init::ROTATED_CO_PSK;
use crate::harness::x509_fixture::make_pta_chain;
use crate::harness::x509_fixture::pta_pub_from_csr;
use crate::harness::x509_fixture::CaKey;
use crate::harness::SessionHandshake;
use crate::harness::SessionOpenInitOptions;
use crate::harness::TestCtx;

/// `KeyScope` discriminants (wire mirror of the firmware `HsmKeyScope`).
const SCOPE_SESSION: u8 = 0b001;
const SCOPE_EPHEMERAL: u8 = 0b010;
const SCOPE_LOCAL: u8 = 0b011;
const SCOPE_SECURITY_DOMAIN: u8 = 0b100;

/// Crypto-User PSK id.
const CU: u8 = 1;

/// Non-default 32-byte CU PSK, used to clear the default-PSK gate so the
/// CU-role reject path — not the default-PSK gate — is exercised.
const ROTATED_CU_PSK: [u8; PSK_LEN] = [
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F,
];

/// Bring a partition to `Initialized` on a rotated CO session:
/// bootstrap → `PartInit` → `PartFinal`. Post-condition: the
/// Ephemeral/Local masking keys exist, so `SdSealingKeyGen` can mask
/// under them. Returns the live CO session.
///
/// `PartFinal` validates the supplied PTA certificate chain against the
/// POTA trust anchor bound into the policy, so this mints a POTA CA, binds
/// its public key into the policy, and issues a POTA-anchored PTA chain
/// from the `PartInit` CSR (mirrors the `part_final` happy-path setup).
fn finalized_co_session(ctx: &TestCtx) -> SessionHandshake {
let session = bootstrap_rotated_co(ctx, &ROTATED_CO_PSK);

let pota = CaKey::generate();
let policy = part_policy_with_pota(&pota.raw_pub());
let init = ctx
.part_init(&session, &mach_seed(), &policy, &pota_thumbprint())
.expect("PartInit");
let chain = make_pta_chain(&pota, &pta_pub_from_csr(&init.pta_csr));
ctx.part_final(&session, &policy, &[], &chain.der_items())
.expect("PartFinal");
session
}

/// Happy path for a supported `scope`: the masked key + public key are
/// full/non-zero, and a second call yields a distinct keypair.
fn roundtrip_for_scope(scope: u8) {
let ctx = TestCtx::new();
let session = finalized_co_session(&ctx);

let req = TborSdSealingKeyGenReq {
session_id: session.session_id,
scope,
};
let resp = ctx.tbor(&req).expect("SdSealingKeyGen roundtrip");

// Masked private key: exactly the pinned length, non-zero.
assert_eq!(resp.masked_key.len(), MASKED_SEALING_KEY_LEN);
assert!(
resp.masked_key.iter().any(|&b| b != 0),
"masked_key must not be all-zero",
);
// Public key: a full, non-zero P-384 point.
assert_eq!(resp.pub_key.len(), SD_SEALING_PUB_KEY_LEN);
assert!(
resp.pub_key.iter().any(|&b| b != 0),
"pub_key must not be all-zero",
);

// Each call generates fresh randomness → a distinct keypair.
let resp2 = ctx.tbor(&req).expect("second SdSealingKeyGen");
assert_ne!(
resp.masked_key, resp2.masked_key,
"each generation must yield a distinct masked key",
);
assert_ne!(
resp.pub_key, resp2.pub_key,
"each generation must yield a distinct public key",
);
}

#[test]
fn sd_sealing_key_gen_ephemeral_roundtrip_emu() {
roundtrip_for_scope(SCOPE_EPHEMERAL);
}

#[test]
fn sd_sealing_key_gen_local_roundtrip_emu() {
roundtrip_for_scope(SCOPE_LOCAL);
}

#[test]
fn sd_sealing_key_gen_rejects_unsupported_scope_emu() {
let ctx = TestCtx::new();
let session = finalized_co_session(&ctx);

// Session and SecurityDomain masking keys are not yet provisioned
// (session-key masking / CreateSD's SDKMK), so both must be rejected
// with the dedicated UnsupportedKeyScope error.
for scope in [SCOPE_SESSION, SCOPE_SECURITY_DOMAIN] {
let req = TborSdSealingKeyGenReq {
session_id: session.session_id,
scope,
};
ctx.expect_fw_reject(&req, TborStatus::UnsupportedKeyScope);
}
}

#[test]
fn sd_sealing_key_gen_rejects_before_finalize_emu() {
let ctx = TestCtx::new();
// Rotated CO session but no PartInit/PartFinal → the partition is not
// Initialized, so the scope's masking key does not exist yet.
let session = bootstrap_rotated_co(&ctx, &ROTATED_CO_PSK);

let req = TborSdSealingKeyGenReq {
session_id: session.session_id,
scope: SCOPE_EPHEMERAL,
};
ctx.expect_fw_reject(&req, TborStatus::InvalidArg);
}

#[test]
fn sd_sealing_key_gen_rejected_on_cu_session_emu() {
let ctx = TestCtx::new();

// Rotate the CU PSK out of the default so the dispatcher's default-PSK
// gate does not fire first; then reopen a CU session under the rotated
// PSK. CU sessions are pinned to `SessionType::PlainText` (CO-only is
// `Authenticated`).
let bootstrap = ctx.open_session(CU, SessionType::PlainText);
ctx.psk_change(bootstrap.handshake(), &ROTATED_CU_PSK)
.expect("rotate CU PSK");
bootstrap.close().expect("close bootstrap CU session");

let opts = SessionOpenInitOptions::new(CU, SessionType::PlainText).with_psk(&ROTATED_CU_PSK);
let pending = ctx
.session_open_init_with_options(opts)
.expect("CU session_open_init under rotated PSK");
let session = ctx
.session_open_finish(pending)
.expect("CU session_open_finish under rotated PSK");

// SdSealingKeyGen is Crypto-Officer-only: the handler's role gate
// (checked before the scope/state gates) rejects a CU session.
let req = TborSdSealingKeyGenReq {
session_id: session.session_id,
scope: SCOPE_EPHEMERAL,
};
ctx.expect_fw_reject(&req, TborStatus::InvalidPermissions);
}

#[test]
fn sd_sealing_key_gen_rejected_on_default_psk_emu() {
let ctx = TestCtx::new();
// Open a CO session WITHOUT rotating the PSK (still the public
// default) — the dispatcher's default-PSK gate must reject the command
// before the handler runs.
let session = ctx.open_session(CO, SessionType::Authenticated);

let req = TborSdSealingKeyGenReq {
session_id: session.session_id(),
scope: SCOPE_EPHEMERAL,
};
ctx.expect_fw_reject(&req, TborStatus::DefaultPskMustRotate);
}
2 changes: 1 addition & 1 deletion docs/tbor-ddi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ single `none` TOC placeholder and no typed body fields.
| `0x06` | `PskChange` | InSession | [`commands/psk_change.md`](./commands/psk_change.md) |
| `0x07` | `PartInit` | InSession | [`commands/part_init.md`](./commands/part_init.md) |
| `0x08` | `PartFinal` | InSession | [`commands/part_final.md`](./commands/part_final.md) |
| `0x09` | `SdSealingKeyGen` (schema-only) | InSession | [`commands/sd_sealing_key_gen.md`](./commands/sd_sealing_key_gen.md) |
| `0x09` | `SdSealingKeyGen` | InSession | [`commands/sd_sealing_key_gen.md`](./commands/sd_sealing_key_gen.md) |
| `0x0A` | `SdCreateRemoteBackup` (schema-only) | InSession | [`commands/sd_create_remote_backup.md`](./commands/sd_create_remote_backup.md) |
| `0x0B` | `SdResealBackup` (schema-only) | InSession | [`commands/sd_reseal_backup.md`](./commands/sd_reseal_backup.md) |
| `0x0C` | `SdRestoreRemoteBackup` (schema-only) | InSession | [`commands/sd_restore_remote_backup.md`](./commands/sd_restore_remote_backup.md) |
Expand Down
51 changes: 39 additions & 12 deletions docs/tbor-ddi/commands/sd_sealing_key_gen.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,38 @@ Licensed under the MIT License.

# SdSealingKeyGen (Opcode 0x09)

**Handler:** _Not yet landed — wire schema only._
**Handler:** `fw/core/lib/src/ddi/tbor/sd_sealing_key_gen.rs`
**Session:** InSession

## Description

Generates a new security-domain sealing key in the active session's
partition vault and returns its key handle. The request carries the
requested key `scope` (lifecycle / visibility domain) as its 1-byte
`KeyScope` discriminant — a wire mirror of the firmware `HsmKeyScope`.
Generates a new security-domain sealing key and returns the **masked**
private key together with the public key. The sealing key is a **P-384
ECC keypair for ECDH key agreement** (ECIES-style seal / unseal).

The private key is **not** stored on the device. It is masked
(AEAD-GCM-256) under the masking key associated with the requested
`scope` and the masked blob is returned to the caller, which re-imports
it (unmask-on-use) when the key is later needed. Because nothing is
persisted, the command records no rollback on the undo log.

The request carries the requested key `scope` (lifecycle / visibility
domain) as its 1-byte `KeyScope` discriminant — a wire mirror of the
firmware `HsmKeyScope`. Scope → masking key:

- `Ephemeral` → the partition `PartitionEphemeralMaskingKey`.
- `Local` → the partition `PartitionLocalMaskingKey`.

Both masking keys are provisioned by `PartFinal`, so the partition must
be in the `Initialized` lifecycle state. The `Session` and
`SecurityDomain` scopes (and any other) are rejected with
`UnsupportedKeyScope` until their masking keys exist (session-key
masking / `CreateSD`'s `SDKMK`). The masked key's metadata records the
sealing key as `derive`-only, `local`, `private`, and never-extractable,
plus the requested scope.

This command is **Crypto-Officer-only**: a Crypto-User session is
rejected with `InvalidPermissions`.

## Request

Expand All @@ -24,33 +47,37 @@ Wire layout: 4-byte header, followed by the TOC entries, then the

| Offset | Field | Type | Description |
|---|---|---|---|
| 4 | `session_id` | `session_id` (inline) | CO/CU session this request is bound to; cross-checked against the SQE-carried session id. |
| 8 | `scope` | `uint8` (inline) | Requested key scope (`KeyScope` discriminant): `0` = Unspecified, `1` = Session, `2` = Ephemeral, `3` = Local, `4` = SecurityDomain, `5` = Internal. Unknown values round-trip as `KeyScope(x)`. |
| 4 | `session_id` | `session_id` (inline) | CO session this request is bound to; cross-checked against the SQE-carried session id. |
| 8 | `scope` | `uint8` (inline) | Requested key scope (`KeyScope` discriminant): `0` = Unspecified, `1` = Session, `2` = Ephemeral, `3` = Local, `4` = SecurityDomain, `5` = Internal. Only `Ephemeral` and `Local` are supported; others return `UnsupportedKeyScope`. |

### Data section

_Empty — both fields are carried inline within their TOC entries._

## Response

Wire layout: 8-byte header, followed by the TOC entry, then the
(empty) data section.
Wire layout: 8-byte header, followed by the TOC entries, then the data
section carrying the masked key and public key.

### TOC entries

| Offset | Field | Type | Description |
|---|---|---|---|
| 8 | `key_handle` | `key_id` (inline) | Vault id (`HsmKeyId`) of the newly generated sealing key, carried as a `KeyId` (TOC entry type 1). |
| 8 | `masked_key` | `buffer` (180 B) | The sealing key's ECC-P384 private half, masked (AEAD-GCM-256) under the scope's masking key: `header(8) ‖ iv(12) ‖ aad(96) ‖ pt(48) ‖ tag(16)`. Not stored on-device. |
| 12 | `pub_key` | `buffer` (96 B) | Raw P-384 public key: `x ‖ y` affine coordinates (48 + 48 bytes, little-endian per coordinate) of the new sealing key. Not a SEC1 point encoding (no `0x04` prefix). |

### Data section

_Empty — `key_handle` is carried inline within its TOC entry._
Carries the 180-byte `masked_key` followed by the 96-byte `pub_key`.

## Errors

| Error | Cause |
|---|---|
| `SessionNotFound` | `session_id` does not refer to an allocated slot |
| `InvalidPermissions` | The calling session is a Crypto User (this command is Crypto-Officer-only) |
| `SessionNotFound` | `session_id` does not refer to an allocated slot, or the slot is not `Active` |
| `InvalidArg` | The partition is not `Initialized` (run `PartFinal` first) |
| `UnsupportedKeyScope` | The requested scope (`Session`, `SecurityDomain`, or other) has no masking key yet |
| `DdiDecodeFailed` | Malformed request body |

## See also
Expand Down
Loading
Loading