diff --git a/.changeset/release-trust-226.md b/.changeset/release-trust-226.md new file mode 100644 index 00000000..4e31b222 --- /dev/null +++ b/.changeset/release-trust-226.md @@ -0,0 +1,16 @@ +--- +"@cloudhal/release-verify": patch +"@cloudhal/core-contracts": patch +--- + +Signed key and yank lists for release bundles (RFC-0046 §9, #226). + +`@cloudhal/core-contracts` gains the `release-keys` root (`keys.json`, signed by an offline root) and the `release-yank-list` root (`yanked.json`, signed by a yank key and expiring 30 days after it is issued). + +`@cloudhal/release-verify` gains: +- `verifyKeys`, which checks a pinned root's signature and that the list is fresh (`expires` at most 13 months after `issuedAt`); +- `verifyYanked`, which checks a signature by an active yank key that `keys.json` lists, and refuses a stale list with `stale-yank-list`; +- `rememberYanks` and the `memory` option of `verifyKeys` and `verifyYanked`, for consumers that keep state: they refuse a rolled-back or pruned yank list, a replayed older `keys.json`, and a revoked or replaced yank key. A `keys.json` may list at most one active yank key; +- `mergeTrust`, which only reduces trust in the pinned keys: it revokes, marks retiring, and narrows a key's validity window, and it never adds a key. + +`verifyRelease` now enforces each key's validity window. A statement's `createdAt` must lie within its key's `[notBefore, notAfter]`. After `notAfter`, a key verifies only while a fresh `keys.json` lists it as retiring; otherwise the release is refused with `key-expired`. `verifyRelease` also refuses a yanked version (`yanked`). `PINNED_KEYS` now carries each key's window. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c15087b6..9bfdcfd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,7 +194,13 @@ jobs: // The active release key (docs/security/release-signing-keys.md; a test keeps them equal). const PINNED_KEY_ID = "cloudhal-release-2026a"; const PINNED_PUBLIC_KEY = "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8="; + const PINNED_NOT_BEFORE = "2026-11-12T00:00:00Z"; + const PINNED_NOT_AFTER = "2027-11-12T00:00:00Z"; const fail = (m) => { console.error(`bundle-sign: ${m}`); process.exit(1); }; + // RFC-0046 §9: no new signature within 90 days of the key's notAfter, so consumers get the + // next key in time. Dry runs check it too, so a dry run warns before a release fails. + const daysLeft = (Date.parse(PINNED_NOT_AFTER) - Date.now()) / 86_400_000; + if (!(daysLeft >= 90)) fail(`${PINNED_KEY_ID} expires on ${PINNED_NOT_AFTER.slice(0, 10)}, under 90 days away: rotate it first (docs/security/release-signing-keys.md, Rotation)`); const dryRun = process.env.DRY_RUN === "true"; const keyId = dryRun ? `dryrun-${process.env.GITHUB_RUN_ID}` : PINNED_KEY_ID; if (process.env.KEY_ID !== keyId) fail("KEY_ID is not the key this run signs with"); @@ -211,6 +217,9 @@ jobs: const raw = pub.export({ format: "der", type: "spki" }).subarray(12).toString("base64"); if (!dryRun && raw !== PINNED_PUBLIC_KEY) fail(`the signing key is not ${PINNED_KEY_ID}`); const statement = readFileSync("bundle/release-statement.json"); + // RFC-0046 §9: a statement is valid only when its createdAt lies in the key's window. + const createdAt = Date.parse(JSON.parse(statement.toString("utf8")).createdAt); + if (!(createdAt >= Date.parse(PINNED_NOT_BEFORE) && createdAt <= Date.parse(PINNED_NOT_AFTER))) fail(`the statement's createdAt is outside ${PINNED_KEY_ID}'s window [${PINNED_NOT_BEFORE}, ${PINNED_NOT_AFTER}]`); const sig = sign(null, statement, key); if (sig.length !== 64 || !verify(null, statement, pub, sig)) fail("the signature does not verify"); writeFileSync("bundle/release-statement.sig", sig); diff --git a/crates/cloudhal-schema/src/generated/mod.rs b/crates/cloudhal-schema/src/generated/mod.rs index fc35e23f..0ece1e1a 100644 --- a/crates/cloudhal-schema/src/generated/mod.rs +++ b/crates/cloudhal-schema/src/generated/mod.rs @@ -13,8 +13,10 @@ pub mod page; pub mod project; pub mod redirect; pub mod release; +pub mod release_keys; pub mod release_manifest; pub mod release_statement; +pub mod release_yank_list; pub mod schedule_run; pub mod section; pub mod site; diff --git a/crates/cloudhal-schema/src/generated/release_keys.rs b/crates/cloudhal-schema/src/generated/release_keys.rs new file mode 100644 index 00000000..30c00377 --- /dev/null +++ b/crates/cloudhal-schema/src/generated/release_keys.rs @@ -0,0 +1,502 @@ +//! GENERATED FILE — DO NOT EDIT. +//! Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) +#![allow(clippy::all, rustdoc::all, dead_code, unused_imports)] +#![allow(clippy::needless_lifetimes)] +#![allow(clippy::match_single_binding)] +#![allow(clippy::clone_on_copy)] + +#[doc = "`KeyId`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct KeyId(::std::string::String); +impl ::std::ops::Deref for KeyId { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: KeyId) -> Self { + value.0 + } +} +impl ::std::str::FromStr for KeyId { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[a-z0-9][a-z0-9-]{2,63}$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z0-9][a-z0-9-]{2,63}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for KeyId { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for KeyId { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for KeyId { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ListedKey`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ListedKey { + #[serde(rename = "keyId")] + pub key_id: KeyId, + #[serde(rename = "notAfter")] + pub not_after: Timestamp, + #[serde(rename = "notBefore")] + pub not_before: Timestamp, + #[doc = "The raw 32-byte Ed25519 public key, base64."] + #[serde(rename = "publicKey")] + pub public_key: ListedKeyPublicKey, + pub role: ListedKeyRole, + pub status: ListedKeyStatus, +} +#[doc = "The raw 32-byte Ed25519 public key, base64."] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ListedKeyPublicKey(::std::string::String); +impl ::std::ops::Deref for ListedKeyPublicKey { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ListedKeyPublicKey) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ListedKeyPublicKey { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[A-Za-z0-9+/]{43}=$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[A-Za-z0-9+/]{43}=$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ListedKeyPublicKey { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ListedKeyPublicKey { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ListedKeyPublicKey { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ListedKeyRole`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ListedKeyRole { + #[serde(rename = "release")] + Release, + #[serde(rename = "yank")] + Yank, +} +impl ::std::fmt::Display for ListedKeyRole { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Release => f.write_str("release"), + Self::Yank => f.write_str("yank"), + } + } +} +impl ::std::str::FromStr for ListedKeyRole { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "release" => Ok(Self::Release), + "yank" => Ok(Self::Yank), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ListedKeyRole { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ListedKeyRole { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ListedKeyStatus`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ListedKeyStatus { + #[serde(rename = "active")] + Active, + #[serde(rename = "retiring")] + Retiring, + #[serde(rename = "revoked")] + Revoked, +} +impl ::std::fmt::Display for ListedKeyStatus { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Active => f.write_str("active"), + Self::Retiring => f.write_str("retiring"), + Self::Revoked => f.write_str("revoked"), + } + } +} +impl ::std::str::FromStr for ListedKeyStatus { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "active" => Ok(Self::Active), + "retiring" => Ok(Self::Retiring), + "revoked" => Ok(Self::Revoked), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ListedKeyStatus { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ListedKeyStatus { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "cloudhal.release-keys@1 (RFC-0046 §9): the root-signed list of Cloudhal's release and yank keys, published as keys.json at https://cloudhal.dev/.well-known/cloudhal/keys.json next to keys.json.sig (a raw 64-byte Ed25519 signature by the offline root over the exact bytes). A consumer uses it only to reduce trust in the keys it pins: revoke, mark retiring, narrow a validity window; it never adds a key. Canonical bytes as for the release statement."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseKeys { + #[doc = "At most 13 months after issuedAt; a consumer refuses an expired list."] + pub expires: Timestamp, + pub format: ReleaseKeysFormat, + #[serde(rename = "issuedAt")] + pub issued_at: Timestamp, + pub keys: ::std::vec::Vec, + #[serde(rename = "schemaVersion")] + pub schema_version: SchemaVersion, + pub signing: ReleaseKeysSigning, +} +#[doc = "`ReleaseKeysFormat`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseKeysFormat { + #[serde(rename = "cloudhal.release-keys@1")] + CloudhalReleaseKeys1, +} +impl ::std::fmt::Display for ReleaseKeysFormat { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::CloudhalReleaseKeys1 => f.write_str("cloudhal.release-keys@1"), + } + } +} +impl ::std::str::FromStr for ReleaseKeysFormat { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "cloudhal.release-keys@1" => Ok(Self::CloudhalReleaseKeys1), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseKeysFormat { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseKeysFormat { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseKeysSigning`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseKeysSigning { + pub algorithm: ReleaseKeysSigningAlgorithm, + #[doc = "The root key that signed this list."] + #[serde(rename = "keyId")] + pub key_id: KeyId, + pub signature: ReleaseKeysSigningSignature, +} +#[doc = "`ReleaseKeysSigningAlgorithm`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseKeysSigningAlgorithm { + Ed25519, +} +impl ::std::fmt::Display for ReleaseKeysSigningAlgorithm { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Ed25519 => f.write_str("Ed25519"), + } + } +} +impl ::std::str::FromStr for ReleaseKeysSigningAlgorithm { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "Ed25519" => Ok(Self::Ed25519), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseKeysSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseKeysSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseKeysSigningSignature`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseKeysSigningSignature { + #[serde(rename = "keys.json.sig")] + KeysJsonSig, +} +impl ::std::fmt::Display for ReleaseKeysSigningSignature { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::KeysJsonSig => f.write_str("keys.json.sig"), + } + } +} +impl ::std::str::FromStr for ReleaseKeysSigningSignature { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "keys.json.sig" => Ok(Self::KeysJsonSig), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseKeysSigningSignature { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseKeysSigningSignature { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time."] +#[derive(:: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct SchemaVersion(i64); +impl ::std::ops::Deref for SchemaVersion { + type Target = i64; + fn deref(&self) -> &i64 { + &self.0 + } +} +impl ::std::convert::From for i64 { + fn from(value: SchemaVersion) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for SchemaVersion { + type Error = self::error::ConversionError; + fn try_from(value: i64) -> ::std::result::Result { + if ![1_i64].contains(&value) { + Err("invalid value".into()) + } else { + Ok(Self(value)) + } + } +} +impl<'de> ::serde::Deserialize<'de> for SchemaVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "RFC 3339 / ISO 8601 instant in UTC."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct Timestamp(pub ::chrono::DateTime<::chrono::offset::Utc>); +impl ::std::ops::Deref for Timestamp { + type Target = ::chrono::DateTime<::chrono::offset::Utc>; + fn deref(&self) -> &::chrono::DateTime<::chrono::offset::Utc> { + &self.0 + } +} +impl ::std::convert::From for ::chrono::DateTime<::chrono::offset::Utc> { + fn from(value: Timestamp) -> Self { + value.0 + } +} +impl ::std::convert::From<::chrono::DateTime<::chrono::offset::Utc>> for Timestamp { + fn from(value: ::chrono::DateTime<::chrono::offset::Utc>) -> Self { + Self(value) + } +} +impl ::std::fmt::Display for Timestamp { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.0.fmt(f) + } +} +impl ::std::str::FromStr for Timestamp { + type Err = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn from_str(value: &str) -> ::std::result::Result { + Ok(Self(value.parse()?)) + } +} +impl ::std::convert::TryFrom<&str> for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: String) -> ::std::result::Result { + value.parse() + } +} +#[doc = " Error types."] +pub mod error { + #[doc = r" Error from a `TryFrom` or `FromStr` implementation."] + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} diff --git a/crates/cloudhal-schema/src/generated/release_yank_list.rs b/crates/cloudhal-schema/src/generated/release_yank_list.rs new file mode 100644 index 00000000..c6668b7f --- /dev/null +++ b/crates/cloudhal-schema/src/generated/release_yank_list.rs @@ -0,0 +1,514 @@ +//! GENERATED FILE — DO NOT EDIT. +//! Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) +#![allow(clippy::all, rustdoc::all, dead_code, unused_imports)] +#![allow(clippy::needless_lifetimes)] +#![allow(clippy::match_single_binding)] +#![allow(clippy::clone_on_copy)] + +#[doc = "`CloudhalVersion`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct CloudhalVersion(::std::string::String); +impl ::std::ops::Deref for CloudhalVersion { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: CloudhalVersion) -> Self { + value.0 + } +} +impl ::std::str::FromStr for CloudhalVersion { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new( + "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$", + ) + .unwrap() + }); + if PATTERN.find(value).is_none() { + return Err ("doesn't match pattern \"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$\"" . into ()) ; + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for CloudhalVersion { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for CloudhalVersion { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for CloudhalVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`KeyId`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct KeyId(::std::string::String); +impl ::std::ops::Deref for KeyId { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: KeyId) -> Self { + value.0 + } +} +impl ::std::str::FromStr for KeyId { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[a-z0-9][a-z0-9-]{2,63}$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z0-9][a-z0-9-]{2,63}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for KeyId { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for KeyId { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for KeyId { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "cloudhal.release-yank-list@1 (RFC-0046 §9): the yank list, published as yanked.json at https://cloudhal.dev/.well-known/cloudhal/yanked.json next to yanked.json.sig (a raw 64-byte Ed25519 signature by a yank-role key from keys.json over the exact bytes). It only reduces trust: versions no consumer may install, and release key ids every consumer must refuse. Re-signed weekly; it expires 30 days after issuedAt, and a stale list blocks new installs only."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseYankList { + #[doc = "Exactly 30 days after issuedAt."] + pub expires: Timestamp, + pub format: ReleaseYankListFormat, + #[serde(rename = "issuedAt")] + pub issued_at: Timestamp, + #[serde(rename = "revokedKeys")] + pub revoked_keys: ::std::vec::Vec, + #[serde(rename = "schemaVersion")] + pub schema_version: SchemaVersion, + #[doc = "Increases by one at every signing. A consumer that keeps state refuses a serial lower than the last one it saw (a rollback to an older, validly signed list)."] + pub serial: ::std::num::NonZeroU64, + pub signing: ReleaseYankListSigning, + pub versions: ::std::vec::Vec, +} +#[doc = "`ReleaseYankListFormat`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseYankListFormat { + #[serde(rename = "cloudhal.release-yank-list@1")] + CloudhalReleaseYankList1, +} +impl ::std::fmt::Display for ReleaseYankListFormat { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::CloudhalReleaseYankList1 => f.write_str("cloudhal.release-yank-list@1"), + } + } +} +impl ::std::str::FromStr for ReleaseYankListFormat { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "cloudhal.release-yank-list@1" => Ok(Self::CloudhalReleaseYankList1), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseYankListFormat { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseYankListFormat { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseYankListRevokedKeysItem`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseYankListRevokedKeysItem { + #[serde(rename = "keyId")] + pub key_id: KeyId, + pub reason: ReleaseYankListRevokedKeysItemReason, + #[serde(rename = "revokedAt")] + pub revoked_at: Timestamp, +} +#[doc = "`ReleaseYankListRevokedKeysItemReason`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseYankListRevokedKeysItemReason(::std::string::String); +impl ::std::ops::Deref for ReleaseYankListRevokedKeysItemReason { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseYankListRevokedKeysItemReason) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseYankListRevokedKeysItemReason { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + if value.chars().count() > 500usize { + return Err("longer than 500 characters".into()); + } + if value.chars().count() < 1usize { + return Err("shorter than 1 characters".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseYankListRevokedKeysItemReason { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseYankListRevokedKeysItemReason { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseYankListRevokedKeysItemReason { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseYankListSigning`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseYankListSigning { + pub algorithm: ReleaseYankListSigningAlgorithm, + #[doc = "The yank-role key that signed this list."] + #[serde(rename = "keyId")] + pub key_id: KeyId, + pub signature: ReleaseYankListSigningSignature, +} +#[doc = "`ReleaseYankListSigningAlgorithm`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseYankListSigningAlgorithm { + Ed25519, +} +impl ::std::fmt::Display for ReleaseYankListSigningAlgorithm { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Ed25519 => f.write_str("Ed25519"), + } + } +} +impl ::std::str::FromStr for ReleaseYankListSigningAlgorithm { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "Ed25519" => Ok(Self::Ed25519), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseYankListSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseYankListSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseYankListSigningSignature`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseYankListSigningSignature { + #[serde(rename = "yanked.json.sig")] + YankedJsonSig, +} +impl ::std::fmt::Display for ReleaseYankListSigningSignature { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::YankedJsonSig => f.write_str("yanked.json.sig"), + } + } +} +impl ::std::str::FromStr for ReleaseYankListSigningSignature { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "yanked.json.sig" => Ok(Self::YankedJsonSig), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseYankListSigningSignature { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseYankListSigningSignature { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseYankListVersionsItem`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseYankListVersionsItem { + pub reason: ReleaseYankListVersionsItemReason, + pub version: CloudhalVersion, + #[serde(rename = "yankedAt")] + pub yanked_at: Timestamp, +} +#[doc = "`ReleaseYankListVersionsItemReason`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseYankListVersionsItemReason(::std::string::String); +impl ::std::ops::Deref for ReleaseYankListVersionsItemReason { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseYankListVersionsItemReason) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseYankListVersionsItemReason { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + if value.chars().count() > 500usize { + return Err("longer than 500 characters".into()); + } + if value.chars().count() < 1usize { + return Err("shorter than 1 characters".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseYankListVersionsItemReason { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseYankListVersionsItemReason { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseYankListVersionsItemReason { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time."] +#[derive(:: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct SchemaVersion(i64); +impl ::std::ops::Deref for SchemaVersion { + type Target = i64; + fn deref(&self) -> &i64 { + &self.0 + } +} +impl ::std::convert::From for i64 { + fn from(value: SchemaVersion) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for SchemaVersion { + type Error = self::error::ConversionError; + fn try_from(value: i64) -> ::std::result::Result { + if ![1_i64].contains(&value) { + Err("invalid value".into()) + } else { + Ok(Self(value)) + } + } +} +impl<'de> ::serde::Deserialize<'de> for SchemaVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "RFC 3339 / ISO 8601 instant in UTC."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct Timestamp(pub ::chrono::DateTime<::chrono::offset::Utc>); +impl ::std::ops::Deref for Timestamp { + type Target = ::chrono::DateTime<::chrono::offset::Utc>; + fn deref(&self) -> &::chrono::DateTime<::chrono::offset::Utc> { + &self.0 + } +} +impl ::std::convert::From for ::chrono::DateTime<::chrono::offset::Utc> { + fn from(value: Timestamp) -> Self { + value.0 + } +} +impl ::std::convert::From<::chrono::DateTime<::chrono::offset::Utc>> for Timestamp { + fn from(value: ::chrono::DateTime<::chrono::offset::Utc>) -> Self { + Self(value) + } +} +impl ::std::fmt::Display for Timestamp { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.0.fmt(f) + } +} +impl ::std::str::FromStr for Timestamp { + type Err = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn from_str(value: &str) -> ::std::result::Result { + Ok(Self(value.parse()?)) + } +} +impl ::std::convert::TryFrom<&str> for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: String) -> ::std::result::Result { + value.parse() + } +} +#[doc = " Error types."] +pub mod error { + #[doc = r" Error from a `TryFrom` or `FromStr` implementation."] + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} diff --git a/crates/cloudhal-schema/src/lib.rs b/crates/cloudhal-schema/src/lib.rs index 30ef60b0..05ee43da 100644 --- a/crates/cloudhal-schema/src/lib.rs +++ b/crates/cloudhal-schema/src/lib.rs @@ -18,8 +18,10 @@ pub use generated::page::Page; pub use generated::project::Project; pub use generated::redirect::Redirect; pub use generated::release::Release; +pub use generated::release_keys::ReleaseKeys; pub use generated::release_manifest::ReleaseManifest; pub use generated::release_statement::ReleaseStatement; +pub use generated::release_yank_list::ReleaseYankList; pub use generated::schedule_run::ScheduleRun; pub use generated::section::Section; pub use generated::site::Site; diff --git a/crates/cloudhal-schema/tests/fixtures.rs b/crates/cloudhal-schema/tests/fixtures.rs index 10d23436..79e4a802 100644 --- a/crates/cloudhal-schema/tests/fixtures.rs +++ b/crates/cloudhal-schema/tests/fixtures.rs @@ -165,3 +165,13 @@ fn release_manifest() { fn release_statement() { check::("release-statement"); } + +#[test] +fn release_keys() { + check::("release-keys"); +} + +#[test] +fn release_yank_list() { + check::("release-yank-list"); +} diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 86bd8ff2..3d87b9d4 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -141,7 +141,7 @@ A `bundle-sign` job runs in the protected environment `release-signing` (a requi 4. signs. The private key (PKCS#8 PEM) is exposed only to this one step's environment. The step reads it once into a `KeyObject` and removes it from the environment. It then checks that the derived public key equals the pinned public key. The active key id and its raw public key are written inline in the `bundle-sign` job, because the job checks out nothing and cannot read a document. A repository test keeps them equal to `docs/security/release-signing-keys.md`. The step then signs, verifies its own signature, and writes `release-statement.sig`. Errors are fixed strings; the key is never echoed and the shell never traces; 5. uploads the five files as the signed artifact. -Phase 1 custody is that environment secret (`RELEASE_SIGNING_KEY`), readable only by this job. Phase 2 moves the key into a KMS reached through GitHub OIDC; the statement already fits its message limit (§2, invariant 6), so nothing a consumer reads changes. +Phase 1 custody is that environment secret (`RELEASE_SIGNING_KEY`), readable only by this job. Phase 3 (deferred, §9) moves the key into a KMS reached through GitHub OIDC; the statement already fits its message limit (§2, invariant 6), so nothing a consumer reads changes. ### 6. Publication on immutable releases @@ -185,16 +185,65 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no 6. Bundles are never installed automatically by the project's tools; `securityRelease` is a highlight, not an auto-update. 7. Every version ships to everyone at the same moment: there is no early-access channel and no audience-restricted asset. -### 9. Keys and trust (hooks designed here, built later) +### 9. Keys and trust - **Two factors for a consumer that verifies both.** Factor 1 is the Ed25519 statement. Factor 2 is the `bundle-build.yml` attestation, verified by the consumer's own tooling. - For a consumer that verifies both, the two factors together defeat theft of the release key alone. - A consumer that verifies only the Ed25519 statement relies on the release key alone. A deployer running inside a Worker, without `gh` or a Sigstore verifier, is one example. A stolen key is enough against such a consumer until the key is revoked; §2 asks every consumer whose tooling can verify the attestation to do so. - Neither factor defeats a malicious commit on `main` or a compromised build dependency, because both factors then cover bad bytes. Required reviewers on `main`, the frozen lockfile, and the consumer's own soak, backup and rollback guards cover those cases. -- **Rollback and freeze.** Binding the statement to the request (§2, invariant 7) refuses an older signed release served under a newer name. It cannot tell a consumer that a newer version exists, or that the version it holds is withdrawn. The expiring yank list below covers that case once it is built. Until then, a consumer learns about versions from the release feed, which it uses for discovery only and never as a trust factor. -- **Key list** `keys.json` (designed, not built): entries `{ keyId, publicKey, role: "release" | "yank", notBefore, notAfter, status: "active" | "retiring" | "revoked" }`, signed by an offline root key held by two people (a hardware key plus a sealed backup), about one year of validity, published at a stable URL and as a release asset. Two release keys overlap at every rotation. The sign job will refuse when the active key's `notAfter` is under 90 days away. A consumer may use `keys.json` only to reduce trust (act on `revoked` or an expired `notAfter`); adding a key to a consumer stays a reviewed change on the consumer's side. -- **Yank list** `yanked.json` (designed, not built): signed by the dedicated `yank` key from `keys.json`, not the release key, so a stolen release key cannot un-yank; it carries `expires`, and a stale list alerts and never blocks verification of other versions. Installed copies keep running. -- Until `keys.json` exists, the pinned keys are the ones in `docs/security/release-signing-keys.md`, and a rotation is announced there before its first use. +- **Rollback and freeze.** Binding the statement to the request (§2, invariant 7) refuses an older signed release served under a newer name. It cannot tell a consumer that a newer version exists, or that the version it holds is withdrawn. The expiring yank list below covers that case. A consumer learns about new versions from the release feed, which it uses for discovery only and never as a trust factor. +- **Key list, yank list and expiry (decided 2026-11-14, #226).** The decisions below replace the earlier sketch of `keys.json` and `yanked.json`. They are built by #226. + - **Documents.** Each is a canonical JSON document with a detached signature, in the same form as the statement: a raw 64-byte Ed25519 signature over the exact bytes. A consumer verifies the downloaded bytes and never re-serialises them. + - `keys.json` is `cloudhal.release-keys@1`, signed in `keys.json.sig`. + - `yanked.json` is `cloudhal.release-yank-list@1`, signed in `yanked.json.sig`. + - Both are contract roots in core-contracts, `release-keys` and `release-yank-list`. + - **Where they live.** The canonical URLs are `https://cloudhal.dev/.well-known/cloudhal/keys.json` and `https://cloudhal.dev/.well-known/cloudhal/yanked.json`, each with its `.sig`. + - Every GitHub release also attaches the copies current when it was made. A release is immutable, so that copy is a snapshot, and a consumer prefers the canonical URL. + - The sources are committed under `security/` in this repository. `apps/docs` (cloudhal.dev) publishes them. + - The signatures make the location trust-free; the location only matters for availability. + - **The root.** An Ed25519 root key signs only `keys.json`, at the yearly rotation or in an emergency. + - The owner generates it offline with `node scripts/release-keys.ts root-init`. The script writes the private key encrypted with a passphrase, never prints it, and writes the public key next to it. + - It has one holder for now, with two sealed copies kept in separate places. A YubiKey (firmware 5.7 or later, for Ed25519) is optional. + - A second holder is added when there is a second maintainer. + - `@cloudhal/release-verify` pins the root's public key, and a repository test keeps it equal to the key doc. + - **`keys.json`.** + - **Entries:** `{ keyId, publicKey, role: "release" | "yank", notBefore, notAfter, status: "active" | "retiring" | "revoked" }`. + - **Freshness:** it carries signed `issuedAt` and `expires`, with `expires` at most 13 months after `issuedAt`. The root re-signs it at every rotation. + - **Rotation overlap:** two release keys overlap at every rotation. The successor is listed at least 120 days before the active key's `notAfter`. For `cloudhal-release-2026a` (`notAfter` 2027-11-12), `cloudhal-release-2027a` is listed by 2027-07-15. + - **The sign job:** `bundle-sign` refuses to sign when fewer than 90 days remain before the active key's `notAfter`, and when the statement's `createdAt` is outside the key's `[notBefore, notAfter]`. + - **The yank key.** `cloudhal-yank-2026a` has `role: "yank"` in `keys.json` and its own GitHub environment, `release-yank`, whose branch policy allows `main` only. + - It signs only `yanked.json`. + - `yanked.json` lists yanked versions and revoked release key ids, so an emergency revocation does not need the offline root. + - It can only reduce trust: nothing in `yanked.json` adds or re-enables a key. The committed source list, `security/yank-list.json`, is append-only: a yanked version or revoked key id is never removed. `sign-yanked` refuses to sign a list that drops an entry of the currently published `yanked.json`, and a reviewed pull request is the only way to change the source. + - **A stolen yank key.** It can yank good versions and revoke good keys (denial of service). Its effect on undoing yanks depends on the consumer: + - **Stateless consumers** (a Worker deployer): it can also *undo* yanks and revocations by signing a fresh list without them. This lasts until the root rotates the yank key. + - **Consumers that keep state** (the CLI): these refuse it. They remember (`YankMemory`): + - the yank key, the highest `serial`, and every yanked version and revoked key id they have seen, and refuse any list from that key that goes back in serial or drops one (`stale-yank-list`); + - the `issuedAt` of the newest `keys.json` they have seen, and refuse an older one (`key-list-invalid`), so a replayed `keys.json` from before a revocation cannot bring a revoked yank key back; + - every yank key a root list has revoked, or that a newer yank key replaced, and refuse it forever (`yank-list-invalid`). + - **The cure** is the root: a new `keys.json` revokes the stolen yank key and lists a new one. A list from another yank key starts a new memory only when a `keys.json` newer than the remembered one lists that key and no longer lists the old one as active. + - **One active yank key.** `keys.json` lists at most one yank key as `active`; `verifyKeys` and `sign-keys` refuse two. A yank key is rotated by replacing it, never by overlapping, so a thief of one key cannot switch to another to start a fresh memory. + - **Revocation becomes permanent through the root.** An emergency revocation goes out at once through `yanked.json`, and the root then records it in `keys.json` within days. Only then does it survive a compromised yank key for stateless consumers. + - **Size.** `sign-yanked` refuses a list above 256 KiB, which is the consumer's limit. Pruning happens only through a yank-key rotation: at about 200 KiB the root lists a new yank key, and its first list keeps only what still matters (versions signed by keys that can still verify). Consumers with memory start a new memory with the new key. + - **`yanked.json` freshness.** It carries signed `issuedAt` and `expires`, with `expires` exactly 30 days after `issuedAt`, and a signed `serial` that increases by one at every signing. + - A consumer that can keep state (the CLI, under the project's `.cloudhal/` or the user's cache directory) refuses a `serial` lower than the last one it saw (`stale-yank-list`). Such a lower serial is a rollback to an older, validly signed list that lacks a recent yank. + - A stateless consumer (a Worker deployer) cannot detect such a rollback inside the list's 30 days. That is the remaining risk. + - The `yank-list` branch that serves the list is append-only. A ruleset blocks force-pushes and deletion and lets only GitHub Actions push to it, so a rollback needs a new commit that the branch history shows. + - A scheduled weekly workflow in `release-yank` re-signs it from the committed source list. That workflow has no required reviewer, because the key can only reduce trust. + - A stale `yanked.json` blocks only `deploy --release` (§11b). Installed sites keep running. + - **Expired keys.** A statement is valid only when its `createdAt` lies within its key's `[notBefore, notAfter]`. The producer (`bundle-sign`) and every consumer enforce this. + - After `notAfter`, the key still verifies statements created before `notAfter`, but only while a fresh, root-signed `keys.json` lists it as `retiring` (not `revoked`). + - With pinned keys only, and no `keys.json`, a key past its `notAfter` is refused (`key-expired`). + - A stolen key could backdate `createdAt`. The answer to that is revocation, through `yanked.json` or `keys.json`. + - **Reduce-only merging.** A consumer starts from its pinned keys. A verified `keys.json` can only revoke a pinned key, mark it `retiring`, or narrow its `[notBefore, notAfter]`. A verified `yanked.json` can only revoke a pinned key or yank a version. Keys that `keys.json` lists but the consumer does not pin are not added: adding a key stays a reviewed change on the consumer's side. + - **Online and offline.** + - Online consumers fetch both documents on every run, and refuse when a fetch or a verification fails (§11b). + - `--from ` requires an unexpired `keys.json` and an unexpired `yanked.json` in that directory. + - **KMS custody is deferred** to a separate follow-up, phase 3: "Release signing: KMS-backed signer (RFC-0046 §9 phase 3)". + - Signing stays in the `release-signing` environment with a required reviewer. + - The reason: there is one maintainer, and a cloud account plus its IAM would add more risk than they remove today. The real gaps, revocation and expiry, are closed by the decisions above. + - The follow-up is triggered by a second maintainer, or by any exposure of the release-key secret. +- Until the first signed `keys.json` is published, the pinned keys are the ones in `docs/security/release-signing-keys.md`, and a rotation is announced there before its first use. ### 10. Later phases (in scope, not in this change) @@ -302,9 +351,10 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - **Which version.** `--release` deploys the CLI's own version and takes no other. The CLI's starter, its config generator and its downgrade guard then match the bundle exactly. To install another version, run that version of the CLI (`npx @cloudhal/cli@ deploy --release`). `next` prereleases work the same way. - **No bundle, no deploy.** The CLI refuses with `release-unavailable` when `v` has no published release, when the release is still a draft (publish-ci's window between attaching assets and publishing), or when it has no bundle assets (`RELEASE_BUNDLES` off, or `v0.8.0` and older). The message says to run `cloudhal deploy` without `--release`. It never falls back to building. - **Revocation.** `PINNED_KEYS` is frozen into each published CLI. A stolen key would therefore stay trusted by every CLI already published, and `--release` sends older versions through exactly those CLIs. So **`deploy --release` ships only after #226's signed `keys.json` exists.** - - On every `--release`, the CLI fetches `keys.json` from its stable URL, falling back to the release asset. It verifies it with the root key the CLI pins, checks its `expires`, and applies it reduce-only. - - A `keys.json` that cannot be fetched, does not verify, or has expired is a refusal. There is no override. - - `--from ` needs `keys.json` in that directory too. The same rules apply, plus a freshness limit (§11c, point 7). + - On every `--release`, the CLI fetches both `keys.json` and `yanked.json` (each with its `.sig`) from their canonical URLs (§9). It verifies them with the root key the CLI pins and the yank key `keys.json` lists, checks both expiries and its stored `YankMemory`, applies them reduce-only, and stores the new memory. + - It never falls back to a release-asset snapshot or a cached copy. A snapshot can be older than a revocation, so an attacker who blocks cloudhal.dev would otherwise roll trust back. + - Either list failing to fetch or verify, either one expired, or a yank list going back on the memory is a refusal. There is no override. + - `--from ` needs `keys.json` and `yanked.json` (with their `.sig`) in that directory too. The same rules apply, the memory included. Both lists must be unexpired, so an offline copy is at most 30 days old where revocations are concerned (§9). - The verifier package itself can ship before #226. Its callers pass the keys they trust. - **Order.** 1. Steps 1 to 5 of §2, with the keys reduced by `keys.json`. @@ -405,7 +455,7 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra - A wrangler older than `manifest.toolchain.wrangler` is refused. - **Migrations.** These need nothing new. The admin Worker applies D1 migrations itself, as it does after every deploy, and `manifest.levels.schedules` feeds the existing downgrade guard. - **Offline and dry runs.** - - `--from ` takes the five assets and `keys.json` from a directory, for offline machines. The same verification runs on them. + - `--from ` takes the five assets, `keys.json` and `yanked.json` (with their `.sig`) from a directory, for offline machines. The same verification runs on them. - `--release --dry-run` downloads, verifies, extracts and writes the configs, then stops before wrangler. It needs no Cloudflare account. #### 11c. Open points for this addendum @@ -414,9 +464,9 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra 2. Should `--release` accept another version than the CLI's own? **[default: no, until §10's runtime configuration lands]** 3. When `gh` is unavailable, does the CLI refuse? **[default: yes. `--allow-single-factor` continues on the release key alone, with a line saying so. A failed attestation is always a refusal]** 4. Where does the full-tarball test run? **[default: opt-in through `CLOUDHAL_RELEASE_ASSETS`, in its own CI job that downloads `v0.9.0`. The 7.2 MB tarball is never committed]** -5. What does a key's `notAfter` mean for releases it has already signed? This is #226's decision 6. Until it is settled, the verifier has no `key-expired` code, and `PINNED_KEYS` carries no dates. **[default: #226's answer]** +5. ~~What does a key's `notAfter` mean for releases it has already signed?~~ **Resolved (#226, §9):** a statement is valid when its `createdAt` lies in the key's window. After `notAfter`, the key verifies only while a fresh `keys.json` lists it as `retiring`; with pinned keys alone it gives `key-expired`. 6. Should a later release record real `theme` and `skills` values in the manifest, so a host can compare a project without the CLI's starter? **[default: not part of this change. Consumers ignore the placeholders]** -7. With `--from`, an old `keys.json` replays a key list from before a revocation. **[default: `--from` refuses a `keys.json` whose signed `issuedAt` is more than 30 days old, and the remaining window is documented. This needs #226's format to carry `issuedAt`. Online runs always fetch the current list]** +7. ~~With `--from`, an old `keys.json` replays a key list from before a revocation.~~ **Resolved (#226, §9):** `--from` requires an unexpired `yanked.json`, at most 30 days old, where emergency revocations land first. It also requires an unexpired `keys.json`. ## Alternatives considered @@ -456,7 +506,7 @@ Today `cloudhal deploy` builds the site with Astro and the admin Worker with wra ## Open questions 1. Is the Astro and wrangler output byte-stable across two CI runs of one tag? The independent rebuild check waits for the answer. -2. Where does `keys.json` live: a `cloudhal.dev/.well-known/` URL, release assets only, or both? **[default: both]** +2. ~~Where does `keys.json` live?~~ **Resolved (#226, §9):** canonically at `https://cloudhal.dev/.well-known/cloudhal/keys.json`, plus a verified snapshot on every release. Online consumers never fall back to the snapshot. 3. What is the source of truth for `compatibleWithPrevious` (a changeset field, a migration annotation)? **[default: `true` unless a release sets it]** 4. Should every `next` prerelease ship a bundle? **[default: yes]** 5. Is the inline `_headers` text in the manifest acceptable, or should it be a signed part? **[default: inline]** diff --git a/docs/security/release-signing-keys.md b/docs/security/release-signing-keys.md index 931fda66..80b94770 100644 --- a/docs/security/release-signing-keys.md +++ b/docs/security/release-signing-keys.md @@ -6,9 +6,9 @@ A consumer pins the keys below by key id, and must refuse a release whose `signi ## Keys -| Key id | Algorithm | Public key (raw 32 bytes, base64) | Status | Since | -|---|---|---|---|---| -| `cloudhal-release-2026a` | Ed25519 | `7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=` | active | 2026-11-12 | +| Key id | Algorithm | Public key (raw 32 bytes, base64) | Status | Since | Not after | +|---|---|---|---|---|---| +| `cloudhal-release-2026a` | Ed25519 | `7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=` | active | 2026-11-12 | 2027-11-12 | Other forms of `cloudhal-release-2026a`, for cross-checking: @@ -22,32 +22,107 @@ Other forms of `cloudhal-release-2026a`, for cross-checking: -----END PUBLIC KEY----- ``` +A release key signs a statement only while its window lasts: the statement's `createdAt` must lie within `[since, not after]`. `bundle-sign` enforces this, and it refuses to sign once the not-after date is less than 90 days away. Every consumer checks it too. The 90 days are the rotation window. + Statuses: - `active` signs new releases. -- `retiring` verifies existing releases and signs nothing new. +- `retiring` signs nothing new. It verifies releases created inside its window, and after its not-after date it does so only while a fresh, root-signed `keys.json` lists it as `retiring`. - `revoked` verifies nothing: a consumer refuses every release the key signed. -Releases are immutable and can never gain an asset, so a release signed by a revoked key can never be re-signed. The remedy is a new version signed by an active key. Later, the signed `keys.json` and yank list of RFC-0046 §9 will record that decision. Releases up to and including `v0.8.0` carry no bundle and no signature. +**Expiry.** With pinned keys only, and no `keys.json`, a consumer refuses a key past its not-after date (`key-expired`). A stolen key could backdate a statement's `createdAt`. The answer to that is revocation, not the date. + +Releases are immutable and can never gain an asset, so a release signed by a revoked key can never be re-signed. The remedy is a new version signed by an active key. Releases up to and including `v0.8.0` carry no bundle and no signature. + +## Root keys + +The root signs only `keys.json`, at the yearly rotation or in an emergency (RFC-0046 §9). `@cloudhal/release-verify` pins the root's public key as `PINNED_ROOTS`, and a test keeps it equal to this table. + +| Key id | Algorithm | Public key (raw 32 bytes, base64) | Since | +|---|---|---|---| -## Custody +None yet. The owner's root ceremony adds the first row (see *First-time setup* below). + +**Custody.** +- The root is generated offline by `node scripts/release-keys.ts root-init`. The private key is only ever written encrypted with a passphrase, and the script never prints it. +- There is one holder for now, with two sealed copies (the encrypted file plus its passphrase) kept in separate places. A YubiKey with firmware 5.7 or later, which can hold Ed25519, is an option. +- A second holder is added when there is a second maintainer. +- The root is never a GitHub secret. + +## The yank key and the two lists + +- **The yank key.** `cloudhal-yank-2026a` has role `yank` in `keys.json`. It signs only `yanked.json`. + - It lives as the secret `RELEASE_YANK_KEY` of the GitHub environment `release-yank`, whose branch policy allows `main` only. + - It can only reduce trust: it yanks versions and revokes release key ids, and it can never add or re-enable a key. + - A stolen yank key can yank good versions and revoke good keys (denial of service). + - It can also *undo* yanks and revocations for stateless consumers, such as a Worker deployer, by signing a fresh list without them. + - Consumers that keep state, such as the CLI, refuse such a list: they remember every entry and the highest serial they have seen. They also remember the newest `keys.json` and every revoked or replaced yank key, so an older `keys.json` replayed with a list from the stolen key is refused too. + - **One active yank key at a time.** `keys.json` lists at most one yank key as `active`, and `sign-keys` refuses two. Rotate the yank key by replacing it in one `keys.json` (old key `revoked` or removed, new key `active`), never by overlapping. + - The recovery is a root-signed `keys.json` that revokes the stolen key and lists a new one. That is also why every emergency revocation is recorded in `keys.json` within days (*Revocation*, step 2). +- **`keys.json`** (`cloudhal.release-keys@1`, signed by the root in `keys.json.sig`). + - It lists every release and yank key with its window and status. + - It carries signed `issuedAt` and `expires`, at most 13 months apart, and the root re-signs it at every rotation. + - Its source is `security/keys.json`, with `security/keys.json.sig`. +- **`yanked.json`** (`cloudhal.release-yank-list@1`, signed by the yank key in `yanked.json.sig`). + - It lists yanked versions and revoked release key ids. + - It expires exactly 30 days after `issuedAt`, and carries a signed `serial`, one higher at every signing. A weekly workflow in `release-yank` re-signs it from `security/yank-list.json`, which is append-only. + - The CLI remembers the highest serial it has seen and refuses a lower one, which would be a rolled-back list. A stateless consumer, such as a Worker, cannot detect a rollback inside the 30 days. + - A stale `yanked.json` blocks `cloudhal deploy --release` only; installed sites keep running. +- **Where they live.** `https://cloudhal.dev/.well-known/cloudhal/keys.json` and `.../yanked.json`, each with its `.sig`. Every GitHub release also attaches the copies current when it was made. The signatures make the location trust-free. +- **How consumers use them.** Online consumers fetch both on every run and refuse when a fetch or a check fails. They use both only to reduce trust in the keys they pin (`mergeTrust`). + +## Custody of the release key - In GitHub, the private key exists only as the secret `RELEASE_SIGNING_KEY` (PKCS#8 PEM) of the GitHub environment `release-signing` in `cloudhal-dev/cloudhal`. The environment requires a reviewer and allows deployments from `main` only. -- Only one step of the `bundle-sign` job in `.github/workflows/release.yml` receives the secret. That job checks out no code and runs no package install. It signs only after two checks pass: `gh attestation verify` has checked the files against `bundle-build.yml`, and the statement's digests match the files. Before signing, it checks that the key it was given derives the public key above. The key id and public key are written inline in that job, and a repository test keeps them equal to this file. +- Only one step of the `bundle-sign` job in `.github/workflows/release.yml` receives the secret. That job checks out no code and runs no package install. It signs only after two checks pass: `gh attestation verify` has checked the files against `bundle-build.yml`, and the statement's digests match the files. Before signing, it checks that the key it was given derives the public key above. The key id, public key and window are written inline in that job, and a repository test keeps them equal to this file. - No other job, workflow or repository receives the key. - Outside GitHub, the maintainer holds the key. - The copies are the PKCS#8 file the key was generated into, kept readable by its owner only, and the maintainer's backups. - No copy is ever in a repository, a chat, an issue or a cloud service. - Once the environment secret is set, and before the first signed release, the maintainer moves the generated file to offline storage. From then on, the environment secret and the offline backups are the only copies. -- Phase 2 (designed in RFC-0046, not built) moves the key into a KMS reached through GitHub OIDC, after which no person can export it. +- KMS custody is deferred to phase 3 ("Release signing: KMS-backed signer (RFC-0046 §9 phase 3)"). It is triggered by a second maintainer, or by any exposure of this secret. + +## First-time setup (the owner's ceremony) -## Rotation +This runs once, offline where it says so. Nobody else generates or holds these keys. -1. Generate the new key pair offline. Name it `cloudhal-release-` (for example `cloudhal-release-2027a`). -2. Add a row for it to the table above with status `active` in a reviewed pull request, at least 30 days before its first use, so consumers can pin it in time. Until the signed `keys.json` of RFC-0046 §9 exists, this file is the announcement. -3. Replace the `RELEASE_SIGNING_KEY` secret of `release-signing`, update the inline key id and public key in the `bundle-sign` job (the test that keeps them equal to this file fails until both agree), and run the release workflow's dry run (`workflow_dispatch`, `dry_run: true`). -4. Set the old key's status to `retiring` in this file. Keep it pinned by consumers for as long as releases it signed are installed. -5. A compromised key goes straight to `revoked` here, with an advisory under [SECURITY.md](../../SECURITY.md), and the next release is signed with the new key. +1. **Root.** On an offline machine, run `node scripts/release-keys.ts root-init --key-id cloudhal-root-2026a --out `. It asks for a passphrase twice, then writes `/cloudhal-root-2026a.key.enc` (encrypted PKCS#8) and `/cloudhal-root-2026a.pub.json`. Seal two copies of the encrypted file and the passphrase, in separate places. +2. **Pin the root.** Add its public key to the *Root keys* table above and to `PINNED_ROOTS` in `packages/release-verify/src/trust.ts`, in one reviewed pull request. +3. **Yank key.** Run `node scripts/release-keys.ts yank-init --key-id cloudhal-yank-2026a --out `. It writes `/cloudhal-yank-2026a.pem` (mode 0600, never printed) and `/cloudhal-yank-2026a.pub.json`. +4. **Environment.** In the repository settings, create the environment `release-yank`, with deployment branches `main` only and no required reviewer. Set its secret: `gh secret set RELEASE_YANK_KEY --env release-yank < /cloudhal-yank-2026a.pem`. Then move the `.pem` file to offline storage. +5. **First `keys.json`.** List `cloudhal-release-2026a` and `cloudhal-yank-2026a` in `security/keys.source.json`, then run `node scripts/release-keys.ts sign-keys --root-key /cloudhal-root-2026a.key.enc --root-pub /cloudhal-root-2026a.pub.json`. It writes `security/keys.json` and `security/keys.json.sig`, after verifying the exact signed bytes as a consumer would. +6. **Publish the key list.** Commit `security/keys.json`, `security/keys.json.sig` and `security/keys.source.json` in a reviewed pull request. The yank list needs it on `main` (the workflow reads `security/keys.json`). +7. **Protect the `yank-list` branch** (arrives with the second #226 PR). Add a ruleset for the branch `yank-list`: block force pushes, restrict deletions, and restrict updates with GitHub Actions as the only bypass. +8. **First `yanked.json`** (arrives with the second #226 PR). Run the *Yank list* workflow once by hand with `first_run` checked. After that it runs weekly. +9. **Deploy cloudhal.dev once** (arrives with the second #226 PR). Run `pnpm --filter @cloudhal/docs run deploy`, so that `/.well-known/cloudhal/` serves both lists. + +## Rotation (yearly) + +1. **Start in time.** Start no later than 120 days before the active release key's not-after date: for `cloudhal-release-2026a`, by 2027-07-15. `bundle-sign` refuses once fewer than 90 days remain. +2. **New key.** Generate the new release key offline and name it `cloudhal-release-` (`cloudhal-release-2027a`). +3. **Announce it.** In one reviewed pull request: + - add it to `security/keys.source.json` as `active`, and set the old key to `retiring`; + - re-sign with `sign-keys`, using the root; + - add its row to the *Keys* table above, and add it to `PINNED_KEYS` in `packages/release-verify/src/keys.ts`; + - set the old key's row to `retiring`. + + Consumers pin new keys only through a release of their own, so this happens well before the key's first use. +4. **Switch.** Replace the `RELEASE_SIGNING_KEY` secret of `release-signing`, update the inline key id, public key and window in the `bundle-sign` job (a test keeps them equal to this file), and run the release workflow's dry run (`workflow_dispatch`, `dry_run: true`). +5. **Re-sign yearly.** Re-sign `keys.json` at least once a year, even without a new key, because it expires within 13 months. + +## Revocation (emergency) + +1. **Revoke at once with the yank key.** Add `{ keyId, revokedAt, reason }` to `revokedKeys` in `security/yank-list.json`, in a reviewed pull request, then run the *Yank list* workflow by hand. Within minutes every online consumer refuses the key. This does not need the offline root. +2. **Make it permanent with the root, within days.** Set the key to `revoked` in `security/keys.source.json`, re-sign `keys.json` with the root, publish it, and mark the key's row `revoked` above. Until then, the revocation rests on the yank key alone, which a stateless consumer would lose if the yank key were also stolen. +3. **Tell people.** Publish an advisory under [SECURITY.md](../../SECURITY.md). +4. **Recover.** Rotate to a new release key (*Rotation*, steps 2 to 4) and release a new version signed by it. +5. **A stolen yank key.** It can deny service, and for stateless consumers it can undo yanks and revocations. Revoke it in `keys.json` with the root, generate a new yank key (`yank-init`), list it as the only `active` yank key in the same `keys.json`, and replace `RELEASE_YANK_KEY`. The new key's first list carries every yank and revocation that still matters. Consumers with memory start a new memory with the new key. + +## Yanking a version + +1. **List it.** Add `{ version, yankedAt, reason }` to `versions` in `security/yank-list.json`, in a reviewed pull request. The list is append-only: an entry is never removed, and `sign-yanked` refuses a list that drops an entry of the currently published one. +2. **Re-sign.** Run the *Yank list* workflow by hand. From then on `cloudhal deploy --release` and every other consumer refuse that version. Installed copies keep running. +3. **Fix forward.** Release a fixed version. A yank is never undone. ## Verifying a release @@ -62,44 +137,51 @@ Step 2, `gh release verify-asset`, stays manual. To check by hand instead, follo Download the release's assets into an empty directory, for example with `gh release download v --repo cloudhal-dev/cloudhal --dir `. -### 1. The Ed25519 statement (required) +### 1. The statement, the key lists and every part (required) -With Node 24, from inside that directory (`node verify.mjs `): +Use `@cloudhal/release-verify`, the verifier every consumer shares. In an empty directory, run `npm install @cloudhal/release-verify`. Then run this with Node 24 from the directory holding the assets (`node verify.mjs `): ```js -import { createHash, createPublicKey, verify } from "node:crypto"; -import { readFileSync, statSync } from "node:fs"; +import { createReadStream, readFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import { + mergeTrust, PINNED_KEYS, TRUST_BASE_URL, verifyKeys, verifyRelease, verifyYanked, +} from "@cloudhal/release-verify"; const version = process.argv[2]; -const PINNED = { "cloudhal-release-2026a": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=" }; - -const statementBytes = readFileSync("release-statement.json"); -const sig = readFileSync("release-statement.sig"); -if (statementBytes.length > 4096 || sig.length !== 64) throw new Error("wrong statement or signature size"); -const statement = JSON.parse(statementBytes.toString("utf8")); -const raw = Buffer.from(PINNED[statement.signing.keyId] ?? "", "base64"); -if (raw.length !== 32) throw new Error("key id not pinned"); -// SPKI DER of an Ed25519 public key = this 12-byte prefix + the raw 32 bytes. -const key = createPublicKey({ - key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]), - format: "der", - type: "spki", +const get = async (name) => { + const res = await fetch(TRUST_BASE_URL + name); + if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`); // never fall back to an old copy + return new Uint8Array(await res.arrayBuffer()); +}; +// The root-signed key list and the yank list: fresh, verified, and used only to reduce trust. +const keys = await verifyKeys({ document: await get("keys.json"), signature: await get("keys.json.sig") }); +const yanked = await verifyYanked({ + document: await get("yanked.json"), signature: await get("yanked.json.sig"), keys, }); -if (!verify(null, statementBytes, key, sig)) throw new Error("signature does not verify"); -// Bind the statement to what you asked for (RFC-0046 §2, invariant 7): a validly signed older -// release served under this version's name must be refused. -if (statement.version !== version) throw new Error("statement is for another version"); -if (statement.channel !== (version.includes("-") ? "next" : "stable")) throw new Error("wrong channel"); - -const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); -for (const part of [statement.manifest, statement.tarball]) { - if (sha256(part.name) !== part.sha256 || statSync(part.name).size !== part.bytes) - throw new Error(`${part.name} does not match the statement`); -} -console.log(`ok: ${version} signed by ${statement.signing.keyId}, commit ${statement.commit}`); +const release = await verifyRelease({ + // Invariant 7: refuse a validly signed older release, or another channel, served under this name. + request: { version, channel: version.includes("-") ? "next" : "stable" }, + statement: readFileSync("release-statement.json"), + signature: readFileSync("release-statement.sig"), + manifest: readFileSync("manifest.json"), + tarball: () => Readable.toWeb(createReadStream(`cloudhal-release-${version}.tar.gz`)), + planVersion: 1, + keys: mergeTrust(PINNED_KEYS, { keys, yanked }), // revocations, retiring keys, windows + yanked, // a yanked version is refused +}); +let parts = 0; +for await (const _ of release.entries()) parts++; // every tar entry against the manifest +console.log(`ok: ${version} signed by ${release.statement.signing.keyId}, commit ${release.statement.commit}, ${parts} parts`); ``` -Verify the exact downloaded bytes. Never parse and re-serialise the statement before verifying it. Then check that `manifest.json` names the same `version`, `channel`, `securityRelease`, `commit`, `createdAt` and `signing.keyId` as the statement, and that every entry of the tarball is listed in the manifest with its size and SHA-256 (RFC-0046 §2, steps 5 and 6). +This checks: +- the sizes, the Ed25519 signature over the exact bytes (never parsed and re-serialised first), and that the statement is for the version and channel you asked for; +- that the key was valid when the release was created (`createdAt` in its window), and is still trusted today; +- that the version is not yanked and the key not revoked; +- both schemas, the manifest's binding to the statement, and every entry of the tarball. + +Before the first `keys.json` is published (the root ceremony), drop the `keys` and `yanked` lines and pass `keys: PINNED_KEYS`. The window and expiry are still checked, but yanks and revocations are not. ### 2. GitHub's release record (recommended) diff --git a/packages/cli/test/release-bundle-sign.test.ts b/packages/cli/test/release-bundle-sign.test.ts index fbc83a1f..39ee4f8b 100644 --- a/packages/cli/test/release-bundle-sign.test.ts +++ b/packages/cli/test/release-bundle-sign.test.ts @@ -116,6 +116,57 @@ describe("bundle-sign's inline scripts, run as written", () => { expect(exec(ws, "Check.cjs", env).out).toContain("is not the file the statement pins"); }); + it("refuses to sign within 90 days of the pinned key's not-after date, dry runs too (#226)", async () => { + const ws = await workspace("dryrun-42"); + const env = { + VERSION: "0.8.0", + KEY_ID: "dryrun-42", + GITHUB_SHA: COMMIT, + DRY_RUN: "true", + GITHUB_RUN_ID: "42", + SECURITY_RELEASE: "false", + }; + const sign = readFileSync(path.join(ws, "Sign.cjs"), "utf8"); + const pin = /const PINNED_NOT_AFTER = "[^"]+";/; + expect(sign).toMatch(pin); + const withNotAfter = (days: number) => { + const at = new Date(Date.now() + days * 86_400_000).toISOString(); + const script = sign.replace(pin, `const PINNED_NOT_AFTER = "${at}";`); + writeFileSync(path.join(ws, "Sign.cjs"), script); + return exec(ws, "Sign.cjs", env); + }; + const near = withNotAfter(89); + expect(near).toMatchObject({ ok: false }); + expect(near.out).toMatch( + /^bundle-sign: cloudhal-release-2026a expires on \d{4}-\d\d-\d\d, under 90 days away/, + ); + // The tests above run the script with the real date: from 90 days before it they fail with + // this message too, which is the reminder to rotate the key. + expect(withNotAfter(-1)).toMatchObject({ ok: false }); + expect(withNotAfter(91)).toMatchObject({ ok: true }); + }); + + it("refuses a statement created outside the pinned key's window (RFC-0046 §9)", async () => { + const ws = await workspace("dryrun-42"); + const env = { + VERSION: "0.8.0", + KEY_ID: "dryrun-42", + GITHUB_SHA: COMMIT, + DRY_RUN: "true", + GITHUB_RUN_ID: "42", + SECURITY_RELEASE: "false", + }; + const sign = readFileSync(path.join(ws, "Sign.cjs"), "utf8"); + const pin = /const PINNED_NOT_BEFORE = "[^"]+";/; + expect(sign).toMatch(pin); + // The fixture statement was created 2026-11-12T08:00:00Z. + const script = sign.replace(pin, 'const PINNED_NOT_BEFORE = "2026-11-12T09:00:00Z";'); + writeFileSync(path.join(ws, "Sign.cjs"), script); + const early = exec(ws, "Sign.cjs", env); + expect(early).toMatchObject({ ok: false }); + expect(early.out).toContain("createdAt is outside cloudhal-release-2026a's window"); + }); + it("outside a dry run, refuses any key but the pinned one and never prints the key", async () => { const ws = await workspace("cloudhal-release-2026a"); const pem = generateKeyPairSync("ed25519") diff --git a/packages/cli/test/release-keys.test.ts b/packages/cli/test/release-keys.test.ts new file mode 100644 index 00000000..2be698bd --- /dev/null +++ b/packages/cli/test/release-keys.test.ts @@ -0,0 +1,554 @@ +/** + * scripts/release-keys.ts (RFC-0046 §9, #226): the root and yank keys a ceremony makes, and the + * lists it signs, checked with the consumer's own verifier. Throwaway keys in temp directories only. + */ +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + checkAppendOnly, + main, + type PublicKeyFile, + rootInit, + signKeys, + signYanked, + yankInit, +} from "../../../scripts/release-keys.ts"; +import { + validateReleaseKeys, + validateReleaseYankList, +} from "../../release-verify/src/generated/validators.js"; +import { + PINNED_KEYS, + rememberYanks, + verifyKeys, + verifyYanked, +} from "../../release-verify/src/index.ts"; +import { job, RELEASE, step } from "./fixtures/release-bundle/workflow-text.ts"; + +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); +const tmp = () => { + const d = mkdtempSync(path.join(os.tmpdir(), "release-keys-")); + dirs.push(d); + return d; +}; +const PASS = "correct horse battery staple"; +// The CLI signs with the real clock, so every window here is relative to it. +const NOW = Math.floor(Date.now() / 1000) * 1000; +const iso = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z"); +const DAY = 86_400_000; +const inMonths = (ms: number, months: number) => { + const d = new Date(ms); + d.setUTCMonth(d.getUTCMonth() + months); + return d.getTime(); +}; +const quiet = () => {}; + +function ceremony() { + const dir = tmp(); + const lines: string[] = []; + const log = (l: string) => lines.push(l); + const root = rootInit({ keyId: "cloudhal-root-2026a", out: dir, passphrase: PASS, log }); + const yank = yankInit({ keyId: "cloudhal-yank-2026a", out: dir, log }); + const source = { + keys: [ + { + keyId: "cloudhal-release-2026a", + publicKey: "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + role: "release", + notBefore: iso(NOW - 30 * DAY), + notAfter: iso(NOW + 330 * DAY), + status: "active", + }, + { + keyId: "cloudhal-yank-2026a", + publicKey: yank.publicKey, + role: "yank", + notBefore: iso(NOW - DAY), + notAfter: iso(NOW + 360 * DAY), + status: "active", + }, + ], + }; + return { dir, root, yank, source, lines }; +} +const rootKeyOf = (dir: string) => + readFileSync(path.join(dir, "cloudhal-root-2026a.key.enc"), "utf8"); +const roots = (root: PublicKeyFile) => ({ [root.keyId]: { publicKey: root.publicKey } }); + +describe("root-init and yank-init", () => { + it("write the private keys encrypted or 0600, the public keys in JSON, and print no secret", () => { + const { dir, root, yank, lines } = ceremony(); + const enc = rootKeyOf(dir); + expect(enc).toContain("BEGIN ENCRYPTED PRIVATE KEY"); + expect(statSync(path.join(dir, "cloudhal-root-2026a.key.enc")).mode & 0o777).toBe(0o600); + const pem = readFileSync(path.join(dir, "cloudhal-yank-2026a.pem"), "utf8"); + expect(statSync(path.join(dir, "cloudhal-yank-2026a.pem")).mode & 0o777).toBe(0o600); + expect( + JSON.parse(readFileSync(path.join(dir, "cloudhal-root-2026a.pub.json"), "utf8")), + ).toEqual(root); + const said = lines.join("\n"); + expect(said).toContain(root.publicKey); + expect(said).toContain(yank.publicKey); + expect(said).not.toContain("PRIVATE KEY"); + expect(said).not.toContain(pem.split("\n")[1] as string); + }); + + it("refuse to overwrite a key, a short passphrase and a key id without its role", () => { + const { dir } = ceremony(); + expect(() => yankInit({ keyId: "cloudhal-yank-2026a", out: dir, log: quiet })).toThrow( + /exists/, + ); + expect(() => + rootInit({ keyId: "cloudhal-root-2026b", out: dir, passphrase: "short", log: quiet }), + ).toThrow(/at least 12/); + expect(() => yankInit({ keyId: "cloudhal-release-2026b", out: dir, log: quiet })).toThrow( + /names its role/, + ); + }); +}); + +describe("sign-keys", () => { + it("signs a key list the consumer verifies, valid for 12 months", async () => { + const { dir, root, source } = ceremony(); + const out = tmp(); + const signed = await signKeys({ + rootKey: rootKeyOf(dir), + passphrase: PASS, + rootPub: root, + source, + out, + now: NOW, + }); + const list = await verifyKeys({ ...signed, roots: roots(root), now: new Date(NOW) }); + expect(list.document.issuedAt).toBe(iso(NOW)); + expect(list.document.expires).toBe(iso(inMonths(NOW, 12))); + expect(readFileSync(path.join(out, "keys.json.sig")).length).toBe(64); + }); + + it("refuses a wrong passphrase, another root's key, and more than 13 months", async () => { + const { dir, root, source } = ceremony(); + const out = tmp(); + const base = { + rootKey: rootKeyOf(dir), + passphrase: PASS, + rootPub: root, + source, + out, + now: NOW, + }; + await expect(signKeys({ ...base, passphrase: "wrong passphrase!" })).rejects.toThrow( + /does not open/, + ); + const other = ceremony(); + await expect(signKeys({ ...base, rootPub: other.root })).rejects.toThrow( + /is not cloudhal-root-2026a/, + ); + await expect(signKeys({ ...base, months: 14 })).rejects.toThrow(/1 to 13 months/); + }); +}); + +describe("sign-yanked", () => { + async function setup() { + const c = ceremony(); + const keysOut = tmp(); + const signedKeys = await signKeys({ + rootKey: rootKeyOf(c.dir), + passphrase: PASS, + rootPub: c.root, + source: c.source, + out: keysOut, + now: NOW, + }); + const keys = await verifyKeys({ ...signedKeys, roots: roots(c.root), now: new Date(NOW) }); + const pem = readFileSync(path.join(c.dir, "cloudhal-yank-2026a.pem"), "utf8"); + return { + ...c, + keys, + pem, + keysDoc: keys.document, + keyList: signedKeys, + keysOut, + rootsOf: roots(c.root), + }; + } + const empty = { versions: [], revokedKeys: [] }; + + it("signs a yank list the consumer verifies, expiring in exactly 30 days", async () => { + const s = await setup(); + const source = { + versions: [ + { version: "0.9.3", yankedAt: "2026-11-19T00:00:00Z", reason: "Broken migration." }, + ], + revokedKeys: [], + }; + const signed = await signYanked({ + keyId: "cloudhal-yank-2026a", + keyPem: s.pem, + keyList: s.keyList, + roots: s.rootsOf, + source, + published: undefined, + out: tmp(), + now: NOW, + }); + const list = await verifyYanked({ ...signed, keys: s.keys, now: new Date(NOW) }); + expect(list.document.versions.map((v) => v.version)).toEqual(["0.9.3"]); + expect(Date.parse(list.document.expires) - Date.parse(list.document.issuedAt)).toBe( + 30 * 86_400_000, + ); + }); + + it("refuses a key that is not keys.json's active yank key", async () => { + const s = await setup(); + const otherDir = tmp(); + const other = yankInit({ keyId: "cloudhal-yank-2026b", out: otherDir, log: quiet }); + const otherPem = readFileSync(path.join(otherDir, "cloudhal-yank-2026b.pem"), "utf8"); + const base = { + keyId: "cloudhal-yank-2026a", + keyPem: s.pem, + keyList: s.keyList, + roots: s.rootsOf, + source: empty, + published: undefined, + out: tmp(), + now: NOW, + }; + expect(other.keyId).toBe("cloudhal-yank-2026b"); + await expect(signYanked({ ...base, keyPem: otherPem })).rejects.toThrow( + /is not cloudhal-yank-2026a/, + ); + await expect(signYanked({ ...base, keyPem: "" })).rejects.toThrow(/missing or not a PKCS#8/); + const revokedList = await signKeys({ + rootKey: rootKeyOf(s.dir), + passphrase: PASS, + rootPub: s.root, + source: { + keys: s.source.keys.map((k) => (k.role === "yank" ? { ...k, status: "revoked" } : k)), + }, + out: tmp(), + now: NOW, + }); + await expect(signYanked({ ...base, keyList: revokedList })).rejects.toThrow( + /not list cloudhal-yank-2026a as an active yank key/, + ); + // keys.json must verify with the root given (here: none pinned yet). + await expect(signYanked({ ...base, roots: {} })).rejects.toThrow(/not a pinned root/); + }); + + it("verifies the exact bytes before writing: an invalid source is never signed", async () => { + const s = await setup(); + const out = tmp(); + const base = { + keyId: "cloudhal-yank-2026a", + keyPem: s.pem, + keyList: s.keyList, + roots: s.rootsOf, + published: undefined, + out, + now: NOW, + }; + const bad = { + versions: [{ version: "0.9.3", yankedAt: "yesterday", reason: "x" }], + revokedKeys: [], + }; + await expect(signYanked({ ...base, source: bad })).rejects.toThrow( + /does not verify, nothing written/, + ); + const extra = { versions: [], revokedKeys: [], extra: true } as never; + await expect(signYanked({ ...base, source: extra })).resolves.toBeDefined(); // only its two lists are signed + const twice = { keys: [s.source.keys[0], s.source.keys[0]] as unknown[] }; + await expect( + signKeys({ + rootKey: rootKeyOf(s.dir), + passphrase: PASS, + rootPub: s.root, + source: twice, + out: tmp(), + now: NOW, + }), + ).rejects.toThrow(/does not verify, nothing written/); + const window = { + keys: [ + { + ...(s.source.keys[0] as object), + notAfter: (s.source.keys[0] as { notBefore: string }).notBefore, + }, + ], + }; + await expect( + signKeys({ + rootKey: rootKeyOf(s.dir), + passphrase: PASS, + rootPub: s.root, + source: window, + out: tmp(), + now: NOW, + }), + ).rejects.toThrow(/does not verify/); + // Two active yank keys: rotation replaces the yank key, it never overlaps. + const yank = s.source.keys[1] as Record; + const overlap = { keys: [...s.source.keys, { ...yank, keyId: "cloudhal-yank-2026b" }] }; + await expect( + signKeys({ + rootKey: rootKeyOf(s.dir), + passphrase: PASS, + rootPub: s.root, + source: overlap, + out: tmp(), + now: NOW, + }), + ).rejects.toThrow(/active yank keys.*at most one/); + const before = readdirSync(out); + await expect(signYanked({ ...base, out, source: bad })).rejects.toThrow(); + expect(readdirSync(out)).toEqual(before); + }); + + it("is append-only: refuses a list that drops a published yank or revocation", () => { + const published = { + versions: [{ version: "0.9.3", yankedAt: "2026-11-19T00:00:00Z", reason: "x" }], + revokedKeys: [ + { keyId: "cloudhal-release-2025a", revokedAt: "2026-11-19T00:00:00Z", reason: "x" }, + ], + }; + expect(() => checkAppendOnly(empty, published)).toThrow(/drops 0.9.3/); + expect(() => checkAppendOnly({ ...published, revokedKeys: [] }, published)).toThrow( + /drops the revoked key cloudhal-release-2025a/, + ); + expect(() => checkAppendOnly(published, published)).not.toThrow(); + expect(() => checkAppendOnly(empty, undefined)).not.toThrow(); + }); + + it("numbers each list one above the published one (rollback protection)", async () => { + const s = await setup(); + const base = { + keyId: "cloudhal-yank-2026a", + keyPem: s.pem, + keyList: s.keyList, + roots: s.rootsOf, + source: empty, + out: tmp(), + now: NOW, + }; + const first = await signYanked({ ...base, published: undefined }); + const firstDoc = JSON.parse(new TextDecoder().decode(first.document)); + expect(firstDoc.serial).toBe(1); + const next = await signYanked({ ...base, published: firstDoc }); + expect(JSON.parse(new TextDecoder().decode(next.document)).serial).toBe(2); + await expect(signYanked({ ...base, published: { ...empty } as never })).rejects.toThrow( + /no serial/, + ); + }); + + it("the CLI reads the key from RELEASE_YANK_KEY, never prints it, and needs --published or --first-run", async () => { + const s = await setup(); + const work = tmp(); + const keysFile = path.join(s.keysOut, "keys.json"); + const listFile = path.join(work, "yank-list.json"); + writeFileSync(listFile, JSON.stringify(empty)); + const out = path.join(work, "out"); + const args = [ + "sign-yanked", + "--key-id", + "cloudhal-yank-2026a", + "--keys", + keysFile, + "--list", + listFile, + "--out", + out, + "--root-pub", + path.join(s.dir, "cloudhal-root-2026a.pub.json"), + ]; + process.env.RELEASE_YANK_KEY = s.pem; + await expect(main(args, quiet)).rejects.toThrow(/--published is required/); + process.env.RELEASE_YANK_KEY = s.pem; + const lines: string[] = []; + await main([...args, "--first-run"], (l) => lines.push(l)); + expect(process.env.RELEASE_YANK_KEY).toBeUndefined(); + expect(lines.join("\n")).not.toContain("PRIVATE KEY"); + expect(readFileSync(path.join(out, "yanked.json.sig")).length).toBe(64); + }); +}); + +describe("a yank-key rotation, end to end", () => { + it("signs the new key's first list over the old one's, carrying every entry, and moves a CLI's memory", async () => { + const c = ceremony(); + const T1 = NOW - 2 * 3_600_000; + const T2 = NOW - 3_600_000; + const sign = (source: { keys: unknown[] }, now: number) => + signKeys({ + rootKey: rootKeyOf(c.dir), + passphrase: PASS, + rootPub: c.root, + source, + out: tmp(), + now, + }); + const verified = (list: { document: Uint8Array; signature: Uint8Array }, now: number) => + verifyKeys({ ...list, roots: roots(c.root), now: new Date(now) }); + const yanks = { + versions: [{ version: "0.9.3", yankedAt: iso(T1), reason: "Broken." }], + revokedKeys: [{ keyId: "cloudhal-release-2025a", revokedAt: iso(T1), reason: "Old." }], + }; + + // 1. A list published under cloudhal-yank-2026a, with keys.json T1. + const keysT1 = await sign(c.source, T1); + const old = await signYanked({ + keyId: "cloudhal-yank-2026a", + keyPem: readFileSync(path.join(c.dir, "cloudhal-yank-2026a.pem"), "utf8"), + keyList: keysT1, + roots: roots(c.root), + source: yanks, + published: undefined, + out: tmp(), + now: T1, + }); + const k1 = await verified(keysT1, T1); + const memoryT1 = rememberYanks(await verifyYanked({ ...old, keys: k1, now: new Date(T1) }), k1); + + // 2. The root signs T2: 2026a revoked, 2026b the only active yank key. + const next = yankInit({ keyId: "cloudhal-yank-2026b", out: c.dir, log: quiet }); + const [release, yank] = c.source.keys as unknown as [ + Record, + Record, + ]; + const keysT2 = await sign( + { + keys: [ + release, + { ...yank, status: "revoked" }, + { ...yank, keyId: "cloudhal-yank-2026b", publicKey: next.publicKey }, + ], + }, + T2, + ); + + // 3. sign-yanked with the new key over the published list: every entry carried forward. + const base = { + keyId: "cloudhal-yank-2026b", + keyPem: readFileSync(path.join(c.dir, "cloudhal-yank-2026b.pem"), "utf8"), + keyList: keysT2, + roots: roots(c.root), + published: JSON.parse(new TextDecoder().decode(old.document)), + out: tmp(), + now: NOW, + }; + await expect( + signYanked({ ...base, source: { versions: [], revokedKeys: [] } }), + ).rejects.toThrow(/drops 0.9.3/); + const fresh = await signYanked({ ...base, source: yanks }); + const doc = JSON.parse(new TextDecoder().decode(fresh.document)); + expect(doc.signing.keyId).toBe("cloudhal-yank-2026b"); + expect(doc.serial).toBe(2); + expect(doc.versions).toEqual(yanks.versions); + expect(doc.revokedKeys).toEqual(yanks.revokedKeys); + + // 4. A CLI that remembers 2026a at T1 accepts it and moves its memory to 2026b at T2. + const k2 = await verifyKeys({ + ...keysT2, + roots: roots(c.root), + now: new Date(NOW), + memory: memoryT1, + }); + const accepted = await verifyYanked({ + ...fresh, + keys: k2, + now: new Date(NOW), + memory: memoryT1, + }); + const memoryT2 = rememberYanks(accepted, k2, memoryT1); + expect(memoryT2).toMatchObject({ + keyId: "cloudhal-yank-2026b", + serial: 2, + keysIssuedAt: iso(T2), + retiredYankKeys: ["cloudhal-yank-2026a"], + }); + + // 5. The old key's list is refused from then on, with either key list. + await expect( + verifyYanked({ ...old, keys: k1, now: new Date(T1), memory: memoryT2 }), + ).rejects.toMatchObject({ code: "key-list-invalid" }); + await expect( + verifyYanked({ ...old, keys: k2, now: new Date(T1), memory: memoryT2 }), + ).rejects.toMatchObject({ code: "yank-list-invalid" }); + }); +}); + +describe("the committed sources under security/ (CI)", () => { + const ROOT_DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../.."); + const read = (f: string) => JSON.parse(readFileSync(path.join(ROOT_DIR, "security", f), "utf8")); + const wrapKeys = (keys: unknown) => ({ + schemaVersion: 1, + format: "cloudhal.release-keys@1", + issuedAt: "2026-11-20T00:00:00Z", + expires: "2027-11-20T00:00:00Z", + signing: { keyId: "cloudhal-root-2026a", algorithm: "Ed25519", signature: "keys.json.sig" }, + keys, + }); + + it("keys.source.json is a valid key list with unique ids and real windows", () => { + const source = read("keys.source.json"); + expect(Object.keys(source)).toEqual(["keys"]); + expect( + validateReleaseKeys(wrapKeys(source.keys)), + JSON.stringify(validateReleaseKeys.errors), + ).toBe(true); + const ids = source.keys.map((k: { keyId: string }) => k.keyId); + expect(new Set(ids).size).toBe(ids.length); + for (const k of source.keys) + expect(Date.parse(k.notBefore)).toBeLessThan(Date.parse(k.notAfter)); + }); + + it("its release keys are PINNED_KEYS and bundle-sign's inline key and window", () => { + const release = read("keys.source.json").keys.filter( + (k: { role: string }) => k.role === "release", + ); + const fromSource = Object.fromEntries( + release.map( + (k: { + keyId: string; + publicKey: string; + status: string; + notBefore: string; + notAfter: string; + }) => [ + k.keyId, + { + publicKey: k.publicKey, + status: k.status, + notBefore: k.notBefore, + notAfter: k.notAfter, + }, + ], + ), + ); + expect(fromSource).toEqual(PINNED_KEYS); + const sign = step(job(RELEASE, "bundle-sign"), "Sign the statement"); + const id = /const PINNED_KEY_ID = "([^"]+)";/.exec(sign)?.[1] as string; + const pinned = fromSource[id] as { notBefore: string; notAfter: string }; + expect(sign).toContain(`const PINNED_NOT_BEFORE = "${pinned.notBefore}";`); + expect(sign).toContain(`const PINNED_NOT_AFTER = "${pinned.notAfter}";`); + }); + + it("yank-list.json is a valid yank list source with unique entries", () => { + const list = read("yank-list.json"); + expect(Object.keys(list).sort()).toEqual(["revokedKeys", "versions"]); + const doc = { + schemaVersion: 1, + format: "cloudhal.release-yank-list@1", + serial: 1, + issuedAt: "2026-11-20T00:00:00Z", + expires: "2026-12-20T00:00:00Z", + signing: { keyId: "cloudhal-yank-2026a", algorithm: "Ed25519", signature: "yanked.json.sig" }, + ...list, + }; + expect(validateReleaseYankList(doc), JSON.stringify(validateReleaseYankList.errors)).toBe(true); + const versions = list.versions.map((v: { version: string }) => v.version); + expect(new Set(versions).size).toBe(versions.length); + }); +}); diff --git a/packages/cli/test/release-workflows.test.ts b/packages/cli/test/release-workflows.test.ts index 98e3bae0..18fe96c0 100644 --- a/packages/cli/test/release-workflows.test.ts +++ b/packages/cli/test/release-workflows.test.ts @@ -137,15 +137,18 @@ describe("release.yml jobs", () => { expect(j.indexOf("Check the statement")).toBeLessThan(j.indexOf("Sign the statement")); }); - it("bundle-sign pins the key the security doc lists as active", () => { - const row = - /\| `(cloudhal-release-\d{4}[a-z])` \| Ed25519 \| `([A-Za-z0-9+/]{43}=)` \| active \|/.exec( - KEYS_DOC, - ); - expect(row).not.toBeNull(); + it("bundle-sign pins a key the security doc lists, with its window (a successor can be announced first)", () => { const sign = step(job(RELEASE, "bundle-sign"), "Sign the statement"); - expect(sign).toContain(`const PINNED_KEY_ID = "${row?.[1]}";`); + const id = /const PINNED_KEY_ID = "([^"]+)";/.exec(sign)?.[1] as string; + // The row of the key bundle-sign uses; other active or retiring rows may stand next to it. + const row = new RegExp( + `\\| \`(${id})\` \\| Ed25519 \\| \`([A-Za-z0-9+/]{43}=)\` \\| (active|retiring) \\| (\\d{4}-\\d\\d-\\d\\d) \\| (\\d{4}-\\d\\d-\\d\\d) \\|`, + ).exec(KEYS_DOC); + expect(row).not.toBeNull(); + expect(row?.[3]).toBe("active"); expect(sign).toContain(`const PINNED_PUBLIC_KEY = "${row?.[2]}";`); + expect(sign).toContain(`const PINNED_NOT_BEFORE = "${row?.[4]}T00:00:00Z";`); + expect(sign).toContain(`const PINNED_NOT_AFTER = "${row?.[5]}T00:00:00Z";`); expect(row?.[1]).toBe("cloudhal-release-2026a"); expect(row?.[2]).toBe("7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8="); }); diff --git a/packages/core-contracts/fixtures/invalid/release-keys/extra-field.json b/packages/core-contracts/fixtures/invalid/release-keys/extra-field.json new file mode 100644 index 00000000..b867ac61 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-keys/extra-field.json @@ -0,0 +1,33 @@ +{ + "reason": "objects are closed (additionalProperties: false)", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2027-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ], + "extra": 1 + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-keys/missing-expires.json b/packages/core-contracts/fixtures/invalid/release-keys/missing-expires.json new file mode 100644 index 00000000..758fa798 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-keys/missing-expires.json @@ -0,0 +1,31 @@ +{ + "reason": "expires is required", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-keys/public-key-short.json b/packages/core-contracts/fixtures/invalid/release-keys/public-key-short.json new file mode 100644 index 00000000..123c393f --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-keys/public-key-short.json @@ -0,0 +1,32 @@ +{ + "reason": "publicKey is 32 raw bytes in base64 (44 characters)", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2027-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "AAAA", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-keys/role-unknown.json b/packages/core-contracts/fixtures/invalid/release-keys/role-unknown.json new file mode 100644 index 00000000..e3ad6825 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-keys/role-unknown.json @@ -0,0 +1,32 @@ +{ + "reason": "role is release or yank", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2027-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "root", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-keys/schema-version-2.json b/packages/core-contracts/fixtures/invalid/release-keys/schema-version-2.json new file mode 100644 index 00000000..9715aa90 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-keys/schema-version-2.json @@ -0,0 +1,32 @@ +{ + "reason": "schemaVersion is 1", + "data": { + "schemaVersion": 2, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2027-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-keys/signature-name-wrong.json b/packages/core-contracts/fixtures/invalid/release-keys/signature-name-wrong.json new file mode 100644 index 00000000..773738e1 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-keys/signature-name-wrong.json @@ -0,0 +1,32 @@ +{ + "reason": "the detached signature is keys.json.sig", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2027-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "release-statement.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-keys/status-unknown.json b/packages/core-contracts/fixtures/invalid/release-keys/status-unknown.json new file mode 100644 index 00000000..a1be8db8 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-keys/status-unknown.json @@ -0,0 +1,32 @@ +{ + "reason": "status is active, retiring or revoked", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2027-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "expired" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-yank-list/extra-field.json b/packages/core-contracts/fixtures/invalid/release-yank-list/extra-field.json new file mode 100644 index 00000000..607b8c91 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-yank-list/extra-field.json @@ -0,0 +1,18 @@ +{ + "reason": "objects are closed (additionalProperties: false)", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 1, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [], + "revokedKeys": [], + "extra": 1 + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-yank-list/missing-revoked-keys.json b/packages/core-contracts/fixtures/invalid/release-yank-list/missing-revoked-keys.json new file mode 100644 index 00000000..c4733d27 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-yank-list/missing-revoked-keys.json @@ -0,0 +1,16 @@ +{ + "reason": "revokedKeys is required (empty when none)", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 1, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-yank-list/missing-serial.json b/packages/core-contracts/fixtures/invalid/release-yank-list/missing-serial.json new file mode 100644 index 00000000..e68965b1 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-yank-list/missing-serial.json @@ -0,0 +1,16 @@ +{ + "reason": "serial is required", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [], + "revokedKeys": [] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-yank-list/reason-empty.json b/packages/core-contracts/fixtures/invalid/release-yank-list/reason-empty.json new file mode 100644 index 00000000..234ef148 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-yank-list/reason-empty.json @@ -0,0 +1,23 @@ +{ + "reason": "a yank says why", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 1, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [ + { + "version": "0.9.3", + "yankedAt": "2026-11-18T09:00:00Z", + "reason": "" + } + ], + "revokedKeys": [] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-yank-list/serial-zero.json b/packages/core-contracts/fixtures/invalid/release-yank-list/serial-zero.json new file mode 100644 index 00000000..5d55216f --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-yank-list/serial-zero.json @@ -0,0 +1,17 @@ +{ + "reason": "serial starts at 1", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 0, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [], + "revokedKeys": [] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-yank-list/signature-name-wrong.json b/packages/core-contracts/fixtures/invalid/release-yank-list/signature-name-wrong.json new file mode 100644 index 00000000..601af935 --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-yank-list/signature-name-wrong.json @@ -0,0 +1,17 @@ +{ + "reason": "the detached signature is yanked.json.sig", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 1, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "versions": [], + "revokedKeys": [] + } +} diff --git a/packages/core-contracts/fixtures/invalid/release-yank-list/version-bad.json b/packages/core-contracts/fixtures/invalid/release-yank-list/version-bad.json new file mode 100644 index 00000000..ee39583f --- /dev/null +++ b/packages/core-contracts/fixtures/invalid/release-yank-list/version-bad.json @@ -0,0 +1,23 @@ +{ + "reason": "a yanked version is a Cloudhal version", + "data": { + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 1, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [ + { + "version": "v0.9.3", + "yankedAt": "2026-11-18T09:00:00Z", + "reason": "x" + } + ], + "revokedKeys": [] + } +} diff --git a/packages/core-contracts/fixtures/valid/release-keys/first.json b/packages/core-contracts/fixtures/valid/release-keys/first.json new file mode 100644 index 00000000..8fed0ba4 --- /dev/null +++ b/packages/core-contracts/fixtures/valid/release-keys/first.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2027-11-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + } + ] +} diff --git a/packages/core-contracts/fixtures/valid/release-keys/rotation.json b/packages/core-contracts/fixtures/valid/release-keys/rotation.json new file mode 100644 index 00000000..26fa93ed --- /dev/null +++ b/packages/core-contracts/fixtures/valid/release-keys/rotation.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "format": "cloudhal.release-keys@1", + "issuedAt": "2027-07-01T00:00:00Z", + "expires": "2028-07-01T00:00:00Z", + "signing": { + "keyId": "cloudhal-root-2026a", + "algorithm": "Ed25519", + "signature": "keys.json.sig" + }, + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "retiring" + }, + { + "keyId": "cloudhal-release-2027a", + "publicKey": "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=", + "role": "release", + "notBefore": "2027-07-15T00:00:00Z", + "notAfter": "2028-11-12T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=", + "role": "yank", + "notBefore": "2026-11-20T00:00:00Z", + "notAfter": "2027-11-20T00:00:00Z", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2025a", + "publicKey": "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=", + "role": "yank", + "notBefore": "2025-11-20T00:00:00Z", + "notAfter": "2026-11-20T00:00:00Z", + "status": "revoked" + } + ] +} diff --git a/packages/core-contracts/fixtures/valid/release-yank-list/empty.json b/packages/core-contracts/fixtures/valid/release-yank-list/empty.json new file mode 100644 index 00000000..7b12a3e2 --- /dev/null +++ b/packages/core-contracts/fixtures/valid/release-yank-list/empty.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 1, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [], + "revokedKeys": [] +} diff --git a/packages/core-contracts/fixtures/valid/release-yank-list/entries.json b/packages/core-contracts/fixtures/valid/release-yank-list/entries.json new file mode 100644 index 00000000..4a221909 --- /dev/null +++ b/packages/core-contracts/fixtures/valid/release-yank-list/entries.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "format": "cloudhal.release-yank-list@1", + "serial": 7, + "issuedAt": "2026-11-20T00:00:00Z", + "expires": "2026-12-20T00:00:00Z", + "signing": { + "keyId": "cloudhal-yank-2026a", + "algorithm": "Ed25519", + "signature": "yanked.json.sig" + }, + "versions": [ + { + "version": "0.9.3", + "yankedAt": "2026-11-18T09:00:00Z", + "reason": "The admin Worker dropped scheduled releases (GHSA-xxxx)." + }, + { + "version": "1.0.0-next.2", + "yankedAt": "2026-11-19T09:00:00Z", + "reason": "Broken migration." + } + ], + "revokedKeys": [ + { + "keyId": "cloudhal-release-2026a", + "revokedAt": "2026-11-19T10:00:00Z", + "reason": "Key material exposed in a CI log." + } + ] +} diff --git a/packages/core-contracts/schemas/release-keys.schema.json b/packages/core-contracts/schemas/release-keys.schema.json new file mode 100644 index 00000000..edb5e8e6 --- /dev/null +++ b/packages/core-contracts/schemas/release-keys.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudhal.dev/schemas/v1/release-keys.schema.json", + "title": "ReleaseKeys", + "description": "cloudhal.release-keys@1 (RFC-0046 §9): the root-signed list of Cloudhal's release and yank keys, published as keys.json at https://cloudhal.dev/.well-known/cloudhal/keys.json next to keys.json.sig (a raw 64-byte Ed25519 signature by the offline root over the exact bytes). A consumer uses it only to reduce trust in the keys it pins: revoke, mark retiring, narrow a validity window; it never adds a key. Canonical bytes as for the release statement.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "format", "issuedAt", "expires", "signing", "keys"], + "properties": { + "schemaVersion": { + "$ref": "common.schema.json#/$defs/SchemaVersion" + }, + "format": { + "type": "string", + "enum": ["cloudhal.release-keys@1"] + }, + "issuedAt": { + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "expires": { + "description": "At most 13 months after issuedAt; a consumer refuses an expired list.", + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "signing": { + "type": "object", + "additionalProperties": false, + "required": ["keyId", "algorithm", "signature"], + "properties": { + "keyId": { + "description": "The root key that signed this list.", + "$ref": "#/$defs/KeyId" + }, + "algorithm": { + "type": "string", + "enum": ["Ed25519"] + }, + "signature": { + "type": "string", + "enum": ["keys.json.sig"] + } + } + }, + "keys": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/ListedKey" + } + } + }, + "$defs": { + "KeyId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{2,63}$" + }, + "ListedKey": { + "type": "object", + "additionalProperties": false, + "required": ["keyId", "publicKey", "role", "notBefore", "notAfter", "status"], + "properties": { + "keyId": { + "$ref": "#/$defs/KeyId" + }, + "publicKey": { + "description": "The raw 32-byte Ed25519 public key, base64.", + "type": "string", + "pattern": "^[A-Za-z0-9+/]{43}=$" + }, + "role": { + "type": "string", + "enum": ["release", "yank"] + }, + "notBefore": { + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "notAfter": { + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "status": { + "type": "string", + "enum": ["active", "retiring", "revoked"] + } + } + } + } +} diff --git a/packages/core-contracts/schemas/release-yank-list.schema.json b/packages/core-contracts/schemas/release-yank-list.schema.json new file mode 100644 index 00000000..ada47405 --- /dev/null +++ b/packages/core-contracts/schemas/release-yank-list.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudhal.dev/schemas/v1/release-yank-list.schema.json", + "title": "ReleaseYankList", + "description": "cloudhal.release-yank-list@1 (RFC-0046 \u00a79): the yank list, published as yanked.json at https://cloudhal.dev/.well-known/cloudhal/yanked.json next to yanked.json.sig (a raw 64-byte Ed25519 signature by a yank-role key from keys.json over the exact bytes). It only reduces trust: versions no consumer may install, and release key ids every consumer must refuse. Re-signed weekly; it expires 30 days after issuedAt, and a stale list blocks new installs only.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "format", + "serial", + "issuedAt", + "expires", + "signing", + "versions", + "revokedKeys" + ], + "properties": { + "schemaVersion": { + "$ref": "common.schema.json#/$defs/SchemaVersion" + }, + "format": { + "type": "string", + "enum": ["cloudhal.release-yank-list@1"] + }, + "serial": { + "description": "Increases by one at every signing. A consumer that keeps state refuses a serial lower than the last one it saw (a rollback to an older, validly signed list).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "issuedAt": { + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "expires": { + "description": "Exactly 30 days after issuedAt.", + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "signing": { + "type": "object", + "additionalProperties": false, + "required": ["keyId", "algorithm", "signature"], + "properties": { + "keyId": { + "description": "The yank-role key that signed this list.", + "$ref": "#/$defs/KeyId" + }, + "algorithm": { + "type": "string", + "enum": ["Ed25519"] + }, + "signature": { + "type": "string", + "enum": ["yanked.json.sig"] + } + } + }, + "versions": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["version", "yankedAt", "reason"], + "properties": { + "version": { + "$ref": "#/$defs/CloudhalVersion" + }, + "yankedAt": { + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + }, + "revokedKeys": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["keyId", "revokedAt", "reason"], + "properties": { + "keyId": { + "$ref": "#/$defs/KeyId" + }, + "revokedAt": { + "$ref": "common.schema.json#/$defs/Timestamp" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + } + }, + "$defs": { + "KeyId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{2,63}$" + }, + "CloudhalVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$" + } + } +} diff --git a/packages/core-contracts/scripts/schema-list.ts b/packages/core-contracts/scripts/schema-list.ts index 0ba0d01e..bc7c7f86 100644 --- a/packages/core-contracts/scripts/schema-list.ts +++ b/packages/core-contracts/scripts/schema-list.ts @@ -25,6 +25,8 @@ export const ROOT_SCHEMAS = [ "glossary", "release-manifest", "release-statement", + "release-keys", + "release-yank-list", ] as const; export type RootSchemaName = (typeof ROOT_SCHEMAS)[number]; @@ -39,6 +41,8 @@ export type RootSchemaName = (typeof ROOT_SCHEMAS)[number]; export const INDEX_EXPORTS: Partial> = { "release-manifest": ["ReleaseManifest", "WorkerArtifact", "BindingTemplate"], "release-statement": ["ReleaseStatement", "StatementAsset"], + "release-keys": ["ReleaseKeys", "ListedKey"], + "release-yank-list": ["ReleaseYankList"], }; export const COMMON_SCHEMA = "common"; diff --git a/packages/core-contracts/src/generated/index.ts b/packages/core-contracts/src/generated/index.ts index 04ec9b18..b83a6e99 100644 --- a/packages/core-contracts/src/generated/index.ts +++ b/packages/core-contracts/src/generated/index.ts @@ -26,3 +26,5 @@ export type { ScheduleRunId, ScheduleRunSubject, ScheduleRunState, ScheduleRun, export type { Glossary, GlossaryEntry } from "./glossary.ts"; export type { ReleaseManifest, WorkerArtifact, BindingTemplate } from "./release-manifest.ts"; export type { ReleaseStatement, StatementAsset } from "./release-statement.ts"; +export type { ReleaseKeys, ListedKey } from "./release-keys.ts"; +export type { ReleaseYankList } from "./release-yank-list.ts"; diff --git a/packages/core-contracts/src/generated/release-keys.ts b/packages/core-contracts/src/generated/release-keys.ts new file mode 100644 index 00000000..592c41b6 --- /dev/null +++ b/packages/core-contracts/src/generated/release-keys.ts @@ -0,0 +1,66 @@ +/* eslint-disable */ +/* biome-ignore-all lint: generated */ +/** + * GENERATED FILE — DO NOT EDIT. + * Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) + */ + +/** + * Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time. + * + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "SchemaVersion". + */ +export type SchemaVersion = 1; +/** + * RFC 3339 / ISO 8601 instant in UTC. + * + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "Timestamp". + */ +export type Timestamp = string; +/** + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "KeyId". + */ +export type KeyId = string; + +/** + * cloudhal.release-keys@1 (RFC-0046 §9): the root-signed list of Cloudhal's release and yank keys, published as keys.json at https://cloudhal.dev/.well-known/cloudhal/keys.json next to keys.json.sig (a raw 64-byte Ed25519 signature by the offline root over the exact bytes). A consumer uses it only to reduce trust in the keys it pins: revoke, mark retiring, narrow a validity window; it never adds a key. Canonical bytes as for the release statement. + */ +export interface ReleaseKeys { + schemaVersion: SchemaVersion; + format: "cloudhal.release-keys@1"; + issuedAt: Timestamp; + /** + * RFC 3339 / ISO 8601 instant in UTC. + */ + expires: string; + signing: { + /** + * The root key that signed this list. + */ + keyId: string; + algorithm: "Ed25519"; + signature: "keys.json.sig"; + }; + /** + * @maxItems 64 + */ + keys: ListedKey[]; +} +/** + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "ListedKey". + */ +export interface ListedKey { + keyId: KeyId; + /** + * The raw 32-byte Ed25519 public key, base64. + */ + publicKey: string; + role: "release" | "yank"; + notBefore: Timestamp; + notAfter: Timestamp; + status: "active" | "retiring" | "revoked"; +} diff --git a/packages/core-contracts/src/generated/release-yank-list.ts b/packages/core-contracts/src/generated/release-yank-list.ts new file mode 100644 index 00000000..12dd3173 --- /dev/null +++ b/packages/core-contracts/src/generated/release-yank-list.ts @@ -0,0 +1,72 @@ +/* eslint-disable */ +/* biome-ignore-all lint: generated */ +/** + * GENERATED FILE — DO NOT EDIT. + * Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) + */ + +/** + * Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time. + * + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "SchemaVersion". + */ +export type SchemaVersion = 1; +/** + * RFC 3339 / ISO 8601 instant in UTC. + * + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "Timestamp". + */ +export type Timestamp = string; +/** + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "CloudhalVersion". + */ +export type CloudhalVersion = string; +/** + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "KeyId". + */ +export type KeyId = string; + +/** + * cloudhal.release-yank-list@1 (RFC-0046 §9): the yank list, published as yanked.json at https://cloudhal.dev/.well-known/cloudhal/yanked.json next to yanked.json.sig (a raw 64-byte Ed25519 signature by a yank-role key from keys.json over the exact bytes). It only reduces trust: versions no consumer may install, and release key ids every consumer must refuse. Re-signed weekly; it expires 30 days after issuedAt, and a stale list blocks new installs only. + */ +export interface ReleaseYankList { + schemaVersion: SchemaVersion; + format: "cloudhal.release-yank-list@1"; + /** + * Increases by one at every signing. A consumer that keeps state refuses a serial lower than the last one it saw (a rollback to an older, validly signed list). + */ + serial: number; + issuedAt: Timestamp; + /** + * RFC 3339 / ISO 8601 instant in UTC. + */ + expires: string; + signing: { + /** + * The yank-role key that signed this list. + */ + keyId: string; + algorithm: "Ed25519"; + signature: "yanked.json.sig"; + }; + /** + * @maxItems 1000 + */ + versions: { + version: CloudhalVersion; + yankedAt: Timestamp; + reason: string; + }[]; + /** + * @maxItems 64 + */ + revokedKeys: { + keyId: KeyId; + revokedAt: Timestamp; + reason: string; + }[]; +} diff --git a/packages/core-contracts/src/generated/schemas.ts b/packages/core-contracts/src/generated/schemas.ts index 24a6d882..89155153 100644 --- a/packages/core-contracts/src/generated/schemas.ts +++ b/packages/core-contracts/src/generated/schemas.ts @@ -9466,6 +9466,280 @@ export const ReleaseStatementSchema = { } } as const; +export const ReleaseKeysSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudhal.dev/schemas/v1/release-keys.schema.json", + "title": "ReleaseKeys", + "description": "cloudhal.release-keys@1 (RFC-0046 §9): the root-signed list of Cloudhal's release and yank keys, published as keys.json at https://cloudhal.dev/.well-known/cloudhal/keys.json next to keys.json.sig (a raw 64-byte Ed25519 signature by the offline root over the exact bytes). A consumer uses it only to reduce trust in the keys it pins: revoke, mark retiring, narrow a validity window; it never adds a key. Canonical bytes as for the release statement.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "format", + "issuedAt", + "expires", + "signing", + "keys" + ], + "properties": { + "schemaVersion": { + "$ref": "#/$defs/SchemaVersion" + }, + "format": { + "type": "string", + "enum": [ + "cloudhal.release-keys@1" + ] + }, + "issuedAt": { + "$ref": "#/$defs/Timestamp" + }, + "expires": { + "description": "At most 13 months after issuedAt; a consumer refuses an expired list.", + "$ref": "#/$defs/Timestamp" + }, + "signing": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyId", + "algorithm", + "signature" + ], + "properties": { + "keyId": { + "description": "The root key that signed this list.", + "$ref": "#/$defs/KeyId" + }, + "algorithm": { + "type": "string", + "enum": [ + "Ed25519" + ] + }, + "signature": { + "type": "string", + "enum": [ + "keys.json.sig" + ] + } + } + }, + "keys": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/ListedKey" + } + } + }, + "$defs": { + "KeyId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{2,63}$" + }, + "ListedKey": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyId", + "publicKey", + "role", + "notBefore", + "notAfter", + "status" + ], + "properties": { + "keyId": { + "$ref": "#/$defs/KeyId" + }, + "publicKey": { + "description": "The raw 32-byte Ed25519 public key, base64.", + "type": "string", + "pattern": "^[A-Za-z0-9+/]{43}=$" + }, + "role": { + "type": "string", + "enum": [ + "release", + "yank" + ] + }, + "notBefore": { + "$ref": "#/$defs/Timestamp" + }, + "notAfter": { + "$ref": "#/$defs/Timestamp" + }, + "status": { + "type": "string", + "enum": [ + "active", + "retiring", + "revoked" + ] + } + } + }, + "SchemaVersion": { + "type": "integer", + "description": "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.", + "enum": [ + 1 + ] + }, + "Timestamp": { + "type": "string", + "description": "RFC 3339 / ISO 8601 instant in UTC.", + "format": "date-time" + } + } +} as const; + +export const ReleaseYankListSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cloudhal.dev/schemas/v1/release-yank-list.schema.json", + "title": "ReleaseYankList", + "description": "cloudhal.release-yank-list@1 (RFC-0046 §9): the yank list, published as yanked.json at https://cloudhal.dev/.well-known/cloudhal/yanked.json next to yanked.json.sig (a raw 64-byte Ed25519 signature by a yank-role key from keys.json over the exact bytes). It only reduces trust: versions no consumer may install, and release key ids every consumer must refuse. Re-signed weekly; it expires 30 days after issuedAt, and a stale list blocks new installs only.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "format", + "serial", + "issuedAt", + "expires", + "signing", + "versions", + "revokedKeys" + ], + "properties": { + "schemaVersion": { + "$ref": "#/$defs/SchemaVersion" + }, + "format": { + "type": "string", + "enum": [ + "cloudhal.release-yank-list@1" + ] + }, + "serial": { + "description": "Increases by one at every signing. A consumer that keeps state refuses a serial lower than the last one it saw (a rollback to an older, validly signed list).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "issuedAt": { + "$ref": "#/$defs/Timestamp" + }, + "expires": { + "description": "Exactly 30 days after issuedAt.", + "$ref": "#/$defs/Timestamp" + }, + "signing": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyId", + "algorithm", + "signature" + ], + "properties": { + "keyId": { + "description": "The yank-role key that signed this list.", + "$ref": "#/$defs/KeyId" + }, + "algorithm": { + "type": "string", + "enum": [ + "Ed25519" + ] + }, + "signature": { + "type": "string", + "enum": [ + "yanked.json.sig" + ] + } + } + }, + "versions": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "yankedAt", + "reason" + ], + "properties": { + "version": { + "$ref": "#/$defs/CloudhalVersion" + }, + "yankedAt": { + "$ref": "#/$defs/Timestamp" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + }, + "revokedKeys": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyId", + "revokedAt", + "reason" + ], + "properties": { + "keyId": { + "$ref": "#/$defs/KeyId" + }, + "revokedAt": { + "$ref": "#/$defs/Timestamp" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + } + } + }, + "$defs": { + "CloudhalVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$" + }, + "KeyId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{2,63}$" + }, + "SchemaVersion": { + "type": "integer", + "description": "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.", + "enum": [ + 1 + ] + }, + "Timestamp": { + "type": "string", + "description": "RFC 3339 / ISO 8601 instant in UTC.", + "format": "date-time" + } + } +} as const; + export const schemas = { "project": ProjectSchema, "site": SiteSchema, @@ -9489,4 +9763,6 @@ export const schemas = { "glossary": GlossarySchema, "release-manifest": ReleaseManifestSchema, "release-statement": ReleaseStatementSchema, + "release-keys": ReleaseKeysSchema, + "release-yank-list": ReleaseYankListSchema, } as const; diff --git a/packages/core-contracts/src/generated/validators.d.ts b/packages/core-contracts/src/generated/validators.d.ts index ff7ae67d..73dbec66 100644 --- a/packages/core-contracts/src/generated/validators.d.ts +++ b/packages/core-contracts/src/generated/validators.d.ts @@ -6,7 +6,7 @@ */ import type { ErrorObject } from "ajv"; -import type { Project, Site, Page, Section, ContentEntry, Snapshot, SkillManifest, Taxonomy, Term, Redirect, Asset, Menu, AnalyticsEvent, AnalyticsBeacon, Comment, CommentSubmission, Release, Automation, ScheduleRun, Glossary, ReleaseManifest, ReleaseStatement } from "./index.ts"; +import type { Project, Site, Page, Section, ContentEntry, Snapshot, SkillManifest, Taxonomy, Term, Redirect, Asset, Menu, AnalyticsEvent, AnalyticsBeacon, Comment, CommentSubmission, Release, Automation, ScheduleRun, Glossary, ReleaseManifest, ReleaseStatement, ReleaseKeys, ReleaseYankList } from "./index.ts"; export interface StandaloneValidator { (data: unknown): data is T; @@ -35,3 +35,5 @@ export declare const validateScheduleRun: StandaloneValidator; export declare const validateGlossary: StandaloneValidator; export declare const validateReleaseManifest: StandaloneValidator; export declare const validateReleaseStatement: StandaloneValidator; +export declare const validateReleaseKeys: StandaloneValidator; +export declare const validateReleaseYankList: StandaloneValidator; diff --git a/packages/core-contracts/src/generated/validators.js b/packages/core-contracts/src/generated/validators.js index b2d7dfba..e4dc0c75 100644 --- a/packages/core-contracts/src/generated/validators.js +++ b/packages/core-contracts/src/generated/validators.js @@ -47919,3 +47919,1335 @@ return errors === 0; } validate252.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; +export const validateReleaseKeys = validate256; +const schema604 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://cloudhal.dev/schemas/v1/release-keys.schema.json","title":"ReleaseKeys","description":"cloudhal.release-keys@1 (RFC-0046 §9): the root-signed list of Cloudhal's release and yank keys, published as keys.json at https://cloudhal.dev/.well-known/cloudhal/keys.json next to keys.json.sig (a raw 64-byte Ed25519 signature by the offline root over the exact bytes). A consumer uses it only to reduce trust in the keys it pins: revoke, mark retiring, narrow a validity window; it never adds a key. Canonical bytes as for the release statement.","type":"object","additionalProperties":false,"required":["schemaVersion","format","issuedAt","expires","signing","keys"],"properties":{"schemaVersion":{"$ref":"#/$defs/SchemaVersion"},"format":{"type":"string","enum":["cloudhal.release-keys@1"]},"issuedAt":{"$ref":"#/$defs/Timestamp"},"expires":{"description":"At most 13 months after issuedAt; a consumer refuses an expired list.","$ref":"#/$defs/Timestamp"},"signing":{"type":"object","additionalProperties":false,"required":["keyId","algorithm","signature"],"properties":{"keyId":{"description":"The root key that signed this list.","$ref":"#/$defs/KeyId"},"algorithm":{"type":"string","enum":["Ed25519"]},"signature":{"type":"string","enum":["keys.json.sig"]}}},"keys":{"type":"array","maxItems":64,"items":{"$ref":"#/$defs/ListedKey"}}},"$defs":{"KeyId":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{2,63}$"},"ListedKey":{"type":"object","additionalProperties":false,"required":["keyId","publicKey","role","notBefore","notAfter","status"],"properties":{"keyId":{"$ref":"#/$defs/KeyId"},"publicKey":{"description":"The raw 32-byte Ed25519 public key, base64.","type":"string","pattern":"^[A-Za-z0-9+/]{43}=$"},"role":{"type":"string","enum":["release","yank"]},"notBefore":{"$ref":"#/$defs/Timestamp"},"notAfter":{"$ref":"#/$defs/Timestamp"},"status":{"type":"string","enum":["active","retiring","revoked"]}}},"SchemaVersion":{"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]},"Timestamp":{"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"}}}; +const schema605 = {"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]}; +const schema606 = {"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"}; +const schema608 = {"type":"string","pattern":"^[a-z0-9][a-z0-9-]{2,63}$"}; +const schema609 = {"type":"object","additionalProperties":false,"required":["keyId","publicKey","role","notBefore","notAfter","status"],"properties":{"keyId":{"$ref":"#/$defs/KeyId"},"publicKey":{"description":"The raw 32-byte Ed25519 public key, base64.","type":"string","pattern":"^[A-Za-z0-9+/]{43}=$"},"role":{"type":"string","enum":["release","yank"]},"notBefore":{"$ref":"#/$defs/Timestamp"},"notAfter":{"$ref":"#/$defs/Timestamp"},"status":{"type":"string","enum":["active","retiring","revoked"]}}}; +const pattern344 = new RegExp("^[A-Za-z0-9+/]{43}=$", "u"); + +function validate257(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate257.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.keyId === undefined){ +const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +if(data.publicKey === undefined){ +const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "publicKey"},message:"must have required property '"+"publicKey"+"'"}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +if(data.role === undefined){ +const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "role"},message:"must have required property '"+"role"+"'"}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +if(data.notBefore === undefined){ +const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "notBefore"},message:"must have required property '"+"notBefore"+"'"}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +if(data.notAfter === undefined){ +const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "notAfter"},message:"must have required property '"+"notAfter"+"'"}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +if(data.status === undefined){ +const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +for(const key0 in data){ +if(!((((((key0 === "keyId") || (key0 === "publicKey")) || (key0 === "role")) || (key0 === "notBefore")) || (key0 === "notAfter")) || (key0 === "status"))){ +const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +} +if(data.keyId !== undefined){ +let data0 = data.keyId; +if(typeof data0 === "string"){ +if(!pattern336.test(data0)){ +const err7 = {instancePath:instancePath+"/keyId",schemaPath:"#/$defs/KeyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +} +else { +const err8 = {instancePath:instancePath+"/keyId",schemaPath:"#/$defs/KeyId/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +if(data.publicKey !== undefined){ +let data1 = data.publicKey; +if(typeof data1 === "string"){ +if(!pattern344.test(data1)){ +const err9 = {instancePath:instancePath+"/publicKey",schemaPath:"#/properties/publicKey/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9+/]{43}=$"},message:"must match pattern \""+"^[A-Za-z0-9+/]{43}=$"+"\""}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +} +else { +const err10 = {instancePath:instancePath+"/publicKey",schemaPath:"#/properties/publicKey/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +if(data.role !== undefined){ +let data2 = data.role; +if(typeof data2 !== "string"){ +const err11 = {instancePath:instancePath+"/role",schemaPath:"#/properties/role/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +} +if(!((data2 === "release") || (data2 === "yank"))){ +const err12 = {instancePath:instancePath+"/role",schemaPath:"#/properties/role/enum",keyword:"enum",params:{allowedValues: schema609.properties.role.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +} +if(data.notBefore !== undefined){ +let data3 = data.notBefore; +if(typeof data3 === "string"){ +if(!(formats0.validate(data3))){ +const err13 = {instancePath:instancePath+"/notBefore",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +} +} +else { +const err14 = {instancePath:instancePath+"/notBefore",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +} +} +if(data.notAfter !== undefined){ +let data4 = data.notAfter; +if(typeof data4 === "string"){ +if(!(formats0.validate(data4))){ +const err15 = {instancePath:instancePath+"/notAfter",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err15]; +} +else { +vErrors.push(err15); +} +errors++; +} +} +else { +const err16 = {instancePath:instancePath+"/notAfter",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err16]; +} +else { +vErrors.push(err16); +} +errors++; +} +} +if(data.status !== undefined){ +let data5 = data.status; +if(typeof data5 !== "string"){ +const err17 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err17]; +} +else { +vErrors.push(err17); +} +errors++; +} +if(!(((data5 === "active") || (data5 === "retiring")) || (data5 === "revoked"))){ +const err18 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema609.properties.status.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err18]; +} +else { +vErrors.push(err18); +} +errors++; +} +} +} +else { +const err19 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err19]; +} +else { +vErrors.push(err19); +} +errors++; +} +validate257.errors = vErrors; +return errors === 0; +} +validate257.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate256(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://cloudhal.dev/schemas/v1/release-keys.schema.json" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate256.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.schemaVersion === undefined){ +const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +if(data.format === undefined){ +const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "format"},message:"must have required property '"+"format"+"'"}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +if(data.issuedAt === undefined){ +const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "issuedAt"},message:"must have required property '"+"issuedAt"+"'"}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +if(data.expires === undefined){ +const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "expires"},message:"must have required property '"+"expires"+"'"}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +if(data.signing === undefined){ +const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "signing"},message:"must have required property '"+"signing"+"'"}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +if(data.keys === undefined){ +const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "keys"},message:"must have required property '"+"keys"+"'"}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +for(const key0 in data){ +if(!((((((key0 === "schemaVersion") || (key0 === "format")) || (key0 === "issuedAt")) || (key0 === "expires")) || (key0 === "signing")) || (key0 === "keys"))){ +const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +} +if(data.schemaVersion !== undefined){ +let data0 = data.schemaVersion; +if(!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0)))){ +const err7 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/type",keyword:"type",params:{type: "integer"},message:"must be integer"}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +if(!(data0 === 1)){ +const err8 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/enum",keyword:"enum",params:{allowedValues: schema605.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +if(data.format !== undefined){ +let data1 = data.format; +if(typeof data1 !== "string"){ +const err9 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +if(!(data1 === "cloudhal.release-keys@1")){ +const err10 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/enum",keyword:"enum",params:{allowedValues: schema604.properties.format.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +if(data.issuedAt !== undefined){ +let data2 = data.issuedAt; +if(typeof data2 === "string"){ +if(!(formats0.validate(data2))){ +const err11 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +} +} +else { +const err12 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +} +if(data.expires !== undefined){ +let data3 = data.expires; +if(typeof data3 === "string"){ +if(!(formats0.validate(data3))){ +const err13 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +} +} +else { +const err14 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +} +} +if(data.signing !== undefined){ +let data4 = data.signing; +if(data4 && typeof data4 == "object" && !Array.isArray(data4)){ +if(data4.keyId === undefined){ +const err15 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"}; +if(vErrors === null){ +vErrors = [err15]; +} +else { +vErrors.push(err15); +} +errors++; +} +if(data4.algorithm === undefined){ +const err16 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "algorithm"},message:"must have required property '"+"algorithm"+"'"}; +if(vErrors === null){ +vErrors = [err16]; +} +else { +vErrors.push(err16); +} +errors++; +} +if(data4.signature === undefined){ +const err17 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "signature"},message:"must have required property '"+"signature"+"'"}; +if(vErrors === null){ +vErrors = [err17]; +} +else { +vErrors.push(err17); +} +errors++; +} +for(const key1 in data4){ +if(!(((key1 === "keyId") || (key1 === "algorithm")) || (key1 === "signature"))){ +const err18 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err18]; +} +else { +vErrors.push(err18); +} +errors++; +} +} +if(data4.keyId !== undefined){ +let data5 = data4.keyId; +if(typeof data5 === "string"){ +if(!pattern336.test(data5)){ +const err19 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""}; +if(vErrors === null){ +vErrors = [err19]; +} +else { +vErrors.push(err19); +} +errors++; +} +} +else { +const err20 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err20]; +} +else { +vErrors.push(err20); +} +errors++; +} +} +if(data4.algorithm !== undefined){ +let data6 = data4.algorithm; +if(typeof data6 !== "string"){ +const err21 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err21]; +} +else { +vErrors.push(err21); +} +errors++; +} +if(!(data6 === "Ed25519")){ +const err22 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/enum",keyword:"enum",params:{allowedValues: schema604.properties.signing.properties.algorithm.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err22]; +} +else { +vErrors.push(err22); +} +errors++; +} +} +if(data4.signature !== undefined){ +let data7 = data4.signature; +if(typeof data7 !== "string"){ +const err23 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err23]; +} +else { +vErrors.push(err23); +} +errors++; +} +if(!(data7 === "keys.json.sig")){ +const err24 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/enum",keyword:"enum",params:{allowedValues: schema604.properties.signing.properties.signature.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err24]; +} +else { +vErrors.push(err24); +} +errors++; +} +} +} +else { +const err25 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err25]; +} +else { +vErrors.push(err25); +} +errors++; +} +} +if(data.keys !== undefined){ +let data8 = data.keys; +if(Array.isArray(data8)){ +if(data8.length > 64){ +const err26 = {instancePath:instancePath+"/keys",schemaPath:"#/properties/keys/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"}; +if(vErrors === null){ +vErrors = [err26]; +} +else { +vErrors.push(err26); +} +errors++; +} +const len0 = data8.length; +for(let i0=0; i0 9007199254740991 || isNaN(data2)){ +const err14 = {instancePath:instancePath+"/serial",schemaPath:"#/properties/serial/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +} +if(data2 < 1 || isNaN(data2)){ +const err15 = {instancePath:instancePath+"/serial",schemaPath:"#/properties/serial/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"}; +if(vErrors === null){ +vErrors = [err15]; +} +else { +vErrors.push(err15); +} +errors++; +} +} +} +if(data.issuedAt !== undefined){ +let data3 = data.issuedAt; +if(typeof data3 === "string"){ +if(!(formats0.validate(data3))){ +const err16 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err16]; +} +else { +vErrors.push(err16); +} +errors++; +} +} +else { +const err17 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err17]; +} +else { +vErrors.push(err17); +} +errors++; +} +} +if(data.expires !== undefined){ +let data4 = data.expires; +if(typeof data4 === "string"){ +if(!(formats0.validate(data4))){ +const err18 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err18]; +} +else { +vErrors.push(err18); +} +errors++; +} +} +else { +const err19 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err19]; +} +else { +vErrors.push(err19); +} +errors++; +} +} +if(data.signing !== undefined){ +let data5 = data.signing; +if(data5 && typeof data5 == "object" && !Array.isArray(data5)){ +if(data5.keyId === undefined){ +const err20 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"}; +if(vErrors === null){ +vErrors = [err20]; +} +else { +vErrors.push(err20); +} +errors++; +} +if(data5.algorithm === undefined){ +const err21 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "algorithm"},message:"must have required property '"+"algorithm"+"'"}; +if(vErrors === null){ +vErrors = [err21]; +} +else { +vErrors.push(err21); +} +errors++; +} +if(data5.signature === undefined){ +const err22 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "signature"},message:"must have required property '"+"signature"+"'"}; +if(vErrors === null){ +vErrors = [err22]; +} +else { +vErrors.push(err22); +} +errors++; +} +for(const key1 in data5){ +if(!(((key1 === "keyId") || (key1 === "algorithm")) || (key1 === "signature"))){ +const err23 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err23]; +} +else { +vErrors.push(err23); +} +errors++; +} +} +if(data5.keyId !== undefined){ +let data6 = data5.keyId; +if(typeof data6 === "string"){ +if(!pattern336.test(data6)){ +const err24 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""}; +if(vErrors === null){ +vErrors = [err24]; +} +else { +vErrors.push(err24); +} +errors++; +} +} +else { +const err25 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err25]; +} +else { +vErrors.push(err25); +} +errors++; +} +} +if(data5.algorithm !== undefined){ +let data7 = data5.algorithm; +if(typeof data7 !== "string"){ +const err26 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err26]; +} +else { +vErrors.push(err26); +} +errors++; +} +if(!(data7 === "Ed25519")){ +const err27 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/enum",keyword:"enum",params:{allowedValues: schema613.properties.signing.properties.algorithm.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err27]; +} +else { +vErrors.push(err27); +} +errors++; +} +} +if(data5.signature !== undefined){ +let data8 = data5.signature; +if(typeof data8 !== "string"){ +const err28 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err28]; +} +else { +vErrors.push(err28); +} +errors++; +} +if(!(data8 === "yanked.json.sig")){ +const err29 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/enum",keyword:"enum",params:{allowedValues: schema613.properties.signing.properties.signature.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err29]; +} +else { +vErrors.push(err29); +} +errors++; +} +} +} +else { +const err30 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err30]; +} +else { +vErrors.push(err30); +} +errors++; +} +} +if(data.versions !== undefined){ +let data9 = data.versions; +if(Array.isArray(data9)){ +if(data9.length > 1000){ +const err31 = {instancePath:instancePath+"/versions",schemaPath:"#/properties/versions/maxItems",keyword:"maxItems",params:{limit: 1000},message:"must NOT have more than 1000 items"}; +if(vErrors === null){ +vErrors = [err31]; +} +else { +vErrors.push(err31); +} +errors++; +} +const len0 = data9.length; +for(let i0=0; i0 500){ +const err40 = {instancePath:instancePath+"/versions/" + i0+"/reason",schemaPath:"#/properties/versions/items/properties/reason/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"}; +if(vErrors === null){ +vErrors = [err40]; +} +else { +vErrors.push(err40); +} +errors++; +} +if(func2(data13) < 1){ +const err41 = {instancePath:instancePath+"/versions/" + i0+"/reason",schemaPath:"#/properties/versions/items/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"}; +if(vErrors === null){ +vErrors = [err41]; +} +else { +vErrors.push(err41); +} +errors++; +} +} +else { +const err42 = {instancePath:instancePath+"/versions/" + i0+"/reason",schemaPath:"#/properties/versions/items/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err42]; +} +else { +vErrors.push(err42); +} +errors++; +} +} +} +else { +const err43 = {instancePath:instancePath+"/versions/" + i0,schemaPath:"#/properties/versions/items/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err43]; +} +else { +vErrors.push(err43); +} +errors++; +} +} +} +else { +const err44 = {instancePath:instancePath+"/versions",schemaPath:"#/properties/versions/type",keyword:"type",params:{type: "array"},message:"must be array"}; +if(vErrors === null){ +vErrors = [err44]; +} +else { +vErrors.push(err44); +} +errors++; +} +} +if(data.revokedKeys !== undefined){ +let data14 = data.revokedKeys; +if(Array.isArray(data14)){ +if(data14.length > 64){ +const err45 = {instancePath:instancePath+"/revokedKeys",schemaPath:"#/properties/revokedKeys/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"}; +if(vErrors === null){ +vErrors = [err45]; +} +else { +vErrors.push(err45); +} +errors++; +} +const len1 = data14.length; +for(let i1=0; i1 500){ +const err54 = {instancePath:instancePath+"/revokedKeys/" + i1+"/reason",schemaPath:"#/properties/revokedKeys/items/properties/reason/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"}; +if(vErrors === null){ +vErrors = [err54]; +} +else { +vErrors.push(err54); +} +errors++; +} +if(func2(data18) < 1){ +const err55 = {instancePath:instancePath+"/revokedKeys/" + i1+"/reason",schemaPath:"#/properties/revokedKeys/items/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"}; +if(vErrors === null){ +vErrors = [err55]; +} +else { +vErrors.push(err55); +} +errors++; +} +} +else { +const err56 = {instancePath:instancePath+"/revokedKeys/" + i1+"/reason",schemaPath:"#/properties/revokedKeys/items/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err56]; +} +else { +vErrors.push(err56); +} +errors++; +} +} +} +else { +const err57 = {instancePath:instancePath+"/revokedKeys/" + i1,schemaPath:"#/properties/revokedKeys/items/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err57]; +} +else { +vErrors.push(err57); +} +errors++; +} +} +} +else { +const err58 = {instancePath:instancePath+"/revokedKeys",schemaPath:"#/properties/revokedKeys/type",keyword:"type",params:{type: "array"},message:"must be array"}; +if(vErrors === null){ +vErrors = [err58]; +} +else { +vErrors.push(err58); +} +errors++; +} +} +} +else { +const err59 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err59]; +} +else { +vErrors.push(err59); +} +errors++; +} +validate259.errors = vErrors; +return errors === 0; +} +validate259.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + diff --git a/packages/core-contracts/src/validate.ts b/packages/core-contracts/src/validate.ts index b51e4e56..6120965e 100644 --- a/packages/core-contracts/src/validate.ts +++ b/packages/core-contracts/src/validate.ts @@ -14,8 +14,10 @@ import type { Project, Redirect, Release, + ReleaseKeys, ReleaseManifest, ReleaseStatement, + ReleaseYankList, ScheduleRun, Section, Site, @@ -50,6 +52,8 @@ export interface ContractTypes { glossary: Glossary; "release-manifest": ReleaseManifest; "release-statement": ReleaseStatement; + "release-keys": ReleaseKeys; + "release-yank-list": ReleaseYankList; } const validators: { [K in RootSchemaName]: v.StandaloneValidator } = { @@ -75,6 +79,8 @@ const validators: { [K in RootSchemaName]: v.StandaloneValidator { }); }); +/** The by-hand verification script in the key doc (its first `js` block after the heading). */ +function handScript(doc: string): string { + const at = doc.indexOf("## Verifying a release by hand"); + const start = doc.indexOf("```js\n", at); + const end = doc.indexOf("\n```", start + 6); + expect(at).toBeGreaterThan(0); + expect(start).toBeGreaterThan(at); + return doc.slice(start + 6, end); +} + describe("RFC-0046 and the release key list", async () => { const rfc = await readFile(path.join(REPO, "docs/rfcs/0046-release-bundles.md"), "utf8"); const keys = await readFile(path.join(REPO, "docs/security/release-signing-keys.md"), "utf8"); @@ -301,10 +311,13 @@ describe("RFC-0046 and the release key list", async () => { expect(steps[2]).toContain("`channel` equals the requested channel"); expect(rfc).toMatch(/^7\. \*\*The statement is for the release the consumer asked for\.\*\*/m); expect(rfc).toMatch(/^4\. Verification hard-fails.*binding the statement to the request/m); - expect(keys).toContain("if (statement.version !== version) throw"); - expect(keys).toContain( - 'if (statement.channel !== (version.includes("-") ? "next" : "stable"))', + // The by-hand script asks the shared verifier for exactly this version and channel. + const script = handScript(keys); + expect(script).toContain( + 'request: { version, channel: version.includes("-") ? "next" : "stable" },', ); + expect(script).toContain("const version = process.argv[2];"); + expect(keys).toContain("the statement is for the version and channel you asked for"); }); it("re-checks the draft's assets immediately before publishing it", () => { @@ -346,8 +359,19 @@ describe("RFC-0046 and the release key list", async () => { ); it("documents the three hand checks", () => { - expect(keys).toContain("crypto"); - expect(keys).toContain(ED25519_SPKI_PREFIX.toString("hex")); + // 1. The statement, through @cloudhal/release-verify: the signature and every digest, the + // key's window and expiry, and the two signed lists (revocations, yanks), never an old copy. + const script = handScript(keys); + expect(script).toContain('from "@cloudhal/release-verify"'); + expect(script).toContain("const keys = await verifyKeys("); + expect(script).toContain("const yanked = await verifyYanked("); + expect(script).toContain("never fall back to an old copy"); + expect(script).toContain("keys: mergeTrust(PINNED_KEYS, { keys, yanked }),"); + expect(script).toMatch(/^\s+yanked,/m); + expect(script).toContain("for await (const _ of release.entries())"); + expect(keys).toContain("that the key was valid when the release was created"); + expect(keys).toContain("that the version is not yanked and the key not revoked"); + // 2. GitHub's release record, 3. the build attestation. expect(keys).toContain("gh release verify-asset"); expect(keys).toContain( "--signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml", diff --git a/packages/release-verify/README.md b/packages/release-verify/README.md index f3a8f104..39b80358 100644 --- a/packages/release-verify/README.md +++ b/packages/release-verify/README.md @@ -45,6 +45,29 @@ There is no soft-fail mode. Every refusal is a `ReleaseVerifyError` with a stabl You can only reduce trust: pass `revoked: [""]` to withdraw a key. Passing your own `keys` map replaces the table, so treat that as a reviewed change. +## Key list and yank list (RFC-0046 §9) + +`keys.json` (signed by an offline root) and `yanked.json` (signed by a yank key) live at `https://cloudhal.dev/.well-known/cloudhal/`, each with its `.sig`. Fetch all four on every run, verify them, and merge them into the keys you pin. They can only take trust away: + +```ts +import { mergeTrust, PINNED_KEYS, verifyKeys, verifyRelease, verifyYanked } from "@cloudhal/release-verify"; + +const keys = await verifyKeys({ document: keysJson, signature: keysSig }); // pinned root, fresh +const yanked = await verifyYanked({ document: yankedJson, signature: yankedSig, keys }); // stale → stale-yank-list +await verifyRelease({ ...input, keys: mergeTrust(PINNED_KEYS, { keys, yanked }), yanked }); +``` + +- **Expiry.** A statement's `createdAt` must lie within its key's `[notBefore, notAfter]`. After `notAfter`, the key verifies only while a fresh `keys.json` lists it as `retiring`. With pinned keys alone, the release is refused with `key-expired`. +- **Yanking.** A yanked version is refused (`yanked`), and a key that `yanked.json` revokes is refused (`key-revoked`). +- **Keep state if you can.** Pass the `memory` you stored to `verifyKeys` and `verifyYanked`, and store `rememberYanks(yanked, keys, memory)` after every accepted pair. Refused: + - a list that goes back in `serial`, or drops a version or key you have already seen yanked or revoked (`stale-yank-list`); + - a `keys.json` older than the newest one you have seen (`key-list-invalid`), so a replayed list cannot bring back a revoked yank key; + - a list signed by a yank key a root list revoked or replaced (`yank-list-invalid`); + - a list from another yank key, unless a newer `keys.json` replaces the remembered one (`stale-yank-list`). + + Without memory, a stolen yank key could undo yanks for you until the root rotates it. A `keys.json` lists at most one active yank key. +- **Not yet published.** `PINNED_ROOTS` is empty until the root is generated. Until then `verifyKeys` refuses every list, unless you pass `roots`. + ## The second factor The Node-only subpath `@cloudhal/release-verify/attestation` checks the GitHub build-provenance attestation of the tarball, the manifest and the statement. It needs `gh` 2.68.0 or newer, signed in. It returns `verified` or `failed`, or `unavailable` when `gh` is missing, too old, or signed out. diff --git a/packages/release-verify/scripts/gen-validators.ts b/packages/release-verify/scripts/gen-validators.ts index 3f519611..26d36c59 100644 --- a/packages/release-verify/scripts/gen-validators.ts +++ b/packages/release-verify/scripts/gen-validators.ts @@ -1,9 +1,9 @@ /** * Writes this package's generated files (RFC-0046 §11a) from the core-contracts sources: * - * src/generated/validators.js ajv standalone validators for exactly the two release roots + * src/generated/validators.js ajv standalone validators for exactly the four release roots * src/generated/validators.d.ts - * src/generated/release-manifest.ts, release-statement.ts the generated contract types + * src/generated/release-*.ts the generated contract types of those roots * * The package never depends on `@cloudhal/core-contracts` at runtime, so a Worker deployer does * not pull in every validator; the validators import only ajv's runtime helpers and the @@ -32,7 +32,7 @@ const BANNER = `/* eslint-disable */ /* biome-ignore-all lint: generated */ /** * GENERATED FILE — DO NOT EDIT. - * Source: packages/core-contracts/schemas/release-{manifest,statement}.schema.json + * Source: packages/core-contracts/schemas/release-{manifest,statement,keys,yank-list}.schema.json * (run \`pnpm gen\`; packages/release-verify/scripts/gen-validators.ts) */ `; @@ -40,6 +40,8 @@ const BANNER = `/* eslint-disable */ const ROOTS = { validateReleaseManifest: "release-manifest", validateReleaseStatement: "release-statement", + validateReleaseKeys: "release-keys", + validateReleaseYankList: "release-yank-list", } as const; /** @@ -81,8 +83,10 @@ export function generate(): Map { "validators.d.ts", [ BANNER, + 'import type { ReleaseKeys } from "./release-keys.ts";', 'import type { ReleaseManifest } from "./release-manifest.ts";', 'import type { ReleaseStatement } from "./release-statement.ts";', + 'import type { ReleaseYankList } from "./release-yank-list.ts";', "", "export interface StandaloneValidator {", " (data: unknown): data is T;", @@ -91,6 +95,8 @@ export function generate(): Map { "", "export declare const validateReleaseManifest: StandaloneValidator;", "export declare const validateReleaseStatement: StandaloneValidator;", + "export declare const validateReleaseKeys: StandaloneValidator;", + "export declare const validateReleaseYankList: StandaloneValidator;", "", ].join("\n"), ); diff --git a/packages/release-verify/src/errors.ts b/packages/release-verify/src/errors.ts index f99cfa20..0d745414 100644 --- a/packages/release-verify/src/errors.ts +++ b/packages/release-verify/src/errors.ts @@ -22,6 +22,13 @@ export const ERROR_CODES = [ "entry-mismatch", "entry-missing", "limit-exceeded", + // RFC-0046 §9 (#226): expiry, the key list and the yank list. + "key-expired", + "yanked", + "key-list-invalid", + "key-list-expired", + "yank-list-invalid", + "stale-yank-list", ] as const; export type ReleaseVerifyErrorCode = (typeof ERROR_CODES)[number]; diff --git a/packages/release-verify/src/generated/release-keys.ts b/packages/release-verify/src/generated/release-keys.ts new file mode 100644 index 00000000..1cf7b7f1 --- /dev/null +++ b/packages/release-verify/src/generated/release-keys.ts @@ -0,0 +1,72 @@ +/* eslint-disable */ +/* biome-ignore-all lint: generated */ +/** + * GENERATED FILE — DO NOT EDIT. + * Source: packages/core-contracts/schemas/release-{manifest,statement,keys,yank-list}.schema.json + * (run `pnpm gen`; packages/release-verify/scripts/gen-validators.ts) + */ +/* biome-ignore-all lint: generated */ +/** + * GENERATED FILE — DO NOT EDIT. + * Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) + */ + +/** + * Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time. + * + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "SchemaVersion". + */ +export type SchemaVersion = 1; +/** + * RFC 3339 / ISO 8601 instant in UTC. + * + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "Timestamp". + */ +export type Timestamp = string; +/** + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "KeyId". + */ +export type KeyId = string; + +/** + * cloudhal.release-keys@1 (RFC-0046 §9): the root-signed list of Cloudhal's release and yank keys, published as keys.json at https://cloudhal.dev/.well-known/cloudhal/keys.json next to keys.json.sig (a raw 64-byte Ed25519 signature by the offline root over the exact bytes). A consumer uses it only to reduce trust in the keys it pins: revoke, mark retiring, narrow a validity window; it never adds a key. Canonical bytes as for the release statement. + */ +export interface ReleaseKeys { + schemaVersion: SchemaVersion; + format: "cloudhal.release-keys@1"; + issuedAt: Timestamp; + /** + * RFC 3339 / ISO 8601 instant in UTC. + */ + expires: string; + signing: { + /** + * The root key that signed this list. + */ + keyId: string; + algorithm: "Ed25519"; + signature: "keys.json.sig"; + }; + /** + * @maxItems 64 + */ + keys: ListedKey[]; +} +/** + * This interface was referenced by `ReleaseKeys`'s JSON-Schema + * via the `definition` "ListedKey". + */ +export interface ListedKey { + keyId: KeyId; + /** + * The raw 32-byte Ed25519 public key, base64. + */ + publicKey: string; + role: "release" | "yank"; + notBefore: Timestamp; + notAfter: Timestamp; + status: "active" | "retiring" | "revoked"; +} diff --git a/packages/release-verify/src/generated/release-manifest.ts b/packages/release-verify/src/generated/release-manifest.ts index b5ea6402..b79dffa5 100644 --- a/packages/release-verify/src/generated/release-manifest.ts +++ b/packages/release-verify/src/generated/release-manifest.ts @@ -2,7 +2,7 @@ /* biome-ignore-all lint: generated */ /** * GENERATED FILE — DO NOT EDIT. - * Source: packages/core-contracts/schemas/release-{manifest,statement}.schema.json + * Source: packages/core-contracts/schemas/release-{manifest,statement,keys,yank-list}.schema.json * (run `pnpm gen`; packages/release-verify/scripts/gen-validators.ts) */ /* biome-ignore-all lint: generated */ diff --git a/packages/release-verify/src/generated/release-statement.ts b/packages/release-verify/src/generated/release-statement.ts index 609a1b92..8f14e5ab 100644 --- a/packages/release-verify/src/generated/release-statement.ts +++ b/packages/release-verify/src/generated/release-statement.ts @@ -2,7 +2,7 @@ /* biome-ignore-all lint: generated */ /** * GENERATED FILE — DO NOT EDIT. - * Source: packages/core-contracts/schemas/release-{manifest,statement}.schema.json + * Source: packages/core-contracts/schemas/release-{manifest,statement,keys,yank-list}.schema.json * (run `pnpm gen`; packages/release-verify/scripts/gen-validators.ts) */ /* biome-ignore-all lint: generated */ diff --git a/packages/release-verify/src/generated/release-yank-list.ts b/packages/release-verify/src/generated/release-yank-list.ts new file mode 100644 index 00000000..f8a99abf --- /dev/null +++ b/packages/release-verify/src/generated/release-yank-list.ts @@ -0,0 +1,78 @@ +/* eslint-disable */ +/* biome-ignore-all lint: generated */ +/** + * GENERATED FILE — DO NOT EDIT. + * Source: packages/core-contracts/schemas/release-{manifest,statement,keys,yank-list}.schema.json + * (run `pnpm gen`; packages/release-verify/scripts/gen-validators.ts) + */ +/* biome-ignore-all lint: generated */ +/** + * GENERATED FILE — DO NOT EDIT. + * Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) + */ + +/** + * Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time. + * + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "SchemaVersion". + */ +export type SchemaVersion = 1; +/** + * RFC 3339 / ISO 8601 instant in UTC. + * + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "Timestamp". + */ +export type Timestamp = string; +/** + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "CloudhalVersion". + */ +export type CloudhalVersion = string; +/** + * This interface was referenced by `ReleaseYankList`'s JSON-Schema + * via the `definition` "KeyId". + */ +export type KeyId = string; + +/** + * cloudhal.release-yank-list@1 (RFC-0046 §9): the yank list, published as yanked.json at https://cloudhal.dev/.well-known/cloudhal/yanked.json next to yanked.json.sig (a raw 64-byte Ed25519 signature by a yank-role key from keys.json over the exact bytes). It only reduces trust: versions no consumer may install, and release key ids every consumer must refuse. Re-signed weekly; it expires 30 days after issuedAt, and a stale list blocks new installs only. + */ +export interface ReleaseYankList { + schemaVersion: SchemaVersion; + format: "cloudhal.release-yank-list@1"; + /** + * Increases by one at every signing. A consumer that keeps state refuses a serial lower than the last one it saw (a rollback to an older, validly signed list). + */ + serial: number; + issuedAt: Timestamp; + /** + * RFC 3339 / ISO 8601 instant in UTC. + */ + expires: string; + signing: { + /** + * The yank-role key that signed this list. + */ + keyId: string; + algorithm: "Ed25519"; + signature: "yanked.json.sig"; + }; + /** + * @maxItems 1000 + */ + versions: { + version: CloudhalVersion; + yankedAt: Timestamp; + reason: string; + }[]; + /** + * @maxItems 64 + */ + revokedKeys: { + keyId: KeyId; + revokedAt: Timestamp; + reason: string; + }[]; +} diff --git a/packages/release-verify/src/generated/validators.d.ts b/packages/release-verify/src/generated/validators.d.ts index a499a390..ef7b16f5 100644 --- a/packages/release-verify/src/generated/validators.d.ts +++ b/packages/release-verify/src/generated/validators.d.ts @@ -2,12 +2,14 @@ /* biome-ignore-all lint: generated */ /** * GENERATED FILE — DO NOT EDIT. - * Source: packages/core-contracts/schemas/release-{manifest,statement}.schema.json + * Source: packages/core-contracts/schemas/release-{manifest,statement,keys,yank-list}.schema.json * (run `pnpm gen`; packages/release-verify/scripts/gen-validators.ts) */ +import type { ReleaseKeys } from "./release-keys.ts"; import type { ReleaseManifest } from "./release-manifest.ts"; import type { ReleaseStatement } from "./release-statement.ts"; +import type { ReleaseYankList } from "./release-yank-list.ts"; export interface StandaloneValidator { (data: unknown): data is T; @@ -16,3 +18,5 @@ export interface StandaloneValidator { export declare const validateReleaseManifest: StandaloneValidator; export declare const validateReleaseStatement: StandaloneValidator; +export declare const validateReleaseKeys: StandaloneValidator; +export declare const validateReleaseYankList: StandaloneValidator; diff --git a/packages/release-verify/src/generated/validators.js b/packages/release-verify/src/generated/validators.js index 5bf9e7c8..50d116af 100644 --- a/packages/release-verify/src/generated/validators.js +++ b/packages/release-verify/src/generated/validators.js @@ -2,7 +2,7 @@ /* biome-ignore-all lint: generated */ /** * GENERATED FILE — DO NOT EDIT. - * Source: packages/core-contracts/schemas/release-{manifest,statement}.schema.json + * Source: packages/core-contracts/schemas/release-{manifest,statement,keys,yank-list}.schema.json * (run `pnpm gen`; packages/release-verify/scripts/gen-validators.ts) */ import __req0 from "ajv/dist/runtime/equal.js"; @@ -4322,3 +4322,1335 @@ return errors === 0; } validate27.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; +export const validateReleaseKeys = validate31; +const schema62 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://cloudhal.dev/schemas/v1/release-keys.schema.json","title":"ReleaseKeys","description":"cloudhal.release-keys@1 (RFC-0046 §9): the root-signed list of Cloudhal's release and yank keys, published as keys.json at https://cloudhal.dev/.well-known/cloudhal/keys.json next to keys.json.sig (a raw 64-byte Ed25519 signature by the offline root over the exact bytes). A consumer uses it only to reduce trust in the keys it pins: revoke, mark retiring, narrow a validity window; it never adds a key. Canonical bytes as for the release statement.","type":"object","additionalProperties":false,"required":["schemaVersion","format","issuedAt","expires","signing","keys"],"properties":{"schemaVersion":{"$ref":"#/$defs/SchemaVersion"},"format":{"type":"string","enum":["cloudhal.release-keys@1"]},"issuedAt":{"$ref":"#/$defs/Timestamp"},"expires":{"description":"At most 13 months after issuedAt; a consumer refuses an expired list.","$ref":"#/$defs/Timestamp"},"signing":{"type":"object","additionalProperties":false,"required":["keyId","algorithm","signature"],"properties":{"keyId":{"description":"The root key that signed this list.","$ref":"#/$defs/KeyId"},"algorithm":{"type":"string","enum":["Ed25519"]},"signature":{"type":"string","enum":["keys.json.sig"]}}},"keys":{"type":"array","maxItems":64,"items":{"$ref":"#/$defs/ListedKey"}}},"$defs":{"KeyId":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{2,63}$"},"ListedKey":{"type":"object","additionalProperties":false,"required":["keyId","publicKey","role","notBefore","notAfter","status"],"properties":{"keyId":{"$ref":"#/$defs/KeyId"},"publicKey":{"description":"The raw 32-byte Ed25519 public key, base64.","type":"string","pattern":"^[A-Za-z0-9+/]{43}=$"},"role":{"type":"string","enum":["release","yank"]},"notBefore":{"$ref":"#/$defs/Timestamp"},"notAfter":{"$ref":"#/$defs/Timestamp"},"status":{"type":"string","enum":["active","retiring","revoked"]}}},"SchemaVersion":{"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]},"Timestamp":{"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"}}}; +const schema63 = {"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]}; +const schema64 = {"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"}; +const schema66 = {"type":"string","pattern":"^[a-z0-9][a-z0-9-]{2,63}$"}; +const schema67 = {"type":"object","additionalProperties":false,"required":["keyId","publicKey","role","notBefore","notAfter","status"],"properties":{"keyId":{"$ref":"#/$defs/KeyId"},"publicKey":{"description":"The raw 32-byte Ed25519 public key, base64.","type":"string","pattern":"^[A-Za-z0-9+/]{43}=$"},"role":{"type":"string","enum":["release","yank"]},"notBefore":{"$ref":"#/$defs/Timestamp"},"notAfter":{"$ref":"#/$defs/Timestamp"},"status":{"type":"string","enum":["active","retiring","revoked"]}}}; +const pattern43 = new RegExp("^[A-Za-z0-9+/]{43}=$", "u"); + +function validate32(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate32.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.keyId === undefined){ +const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +if(data.publicKey === undefined){ +const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "publicKey"},message:"must have required property '"+"publicKey"+"'"}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +if(data.role === undefined){ +const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "role"},message:"must have required property '"+"role"+"'"}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +if(data.notBefore === undefined){ +const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "notBefore"},message:"must have required property '"+"notBefore"+"'"}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +if(data.notAfter === undefined){ +const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "notAfter"},message:"must have required property '"+"notAfter"+"'"}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +if(data.status === undefined){ +const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "status"},message:"must have required property '"+"status"+"'"}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +for(const key0 in data){ +if(!((((((key0 === "keyId") || (key0 === "publicKey")) || (key0 === "role")) || (key0 === "notBefore")) || (key0 === "notAfter")) || (key0 === "status"))){ +const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +} +if(data.keyId !== undefined){ +let data0 = data.keyId; +if(typeof data0 === "string"){ +if(!pattern35.test(data0)){ +const err7 = {instancePath:instancePath+"/keyId",schemaPath:"#/$defs/KeyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +} +else { +const err8 = {instancePath:instancePath+"/keyId",schemaPath:"#/$defs/KeyId/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +if(data.publicKey !== undefined){ +let data1 = data.publicKey; +if(typeof data1 === "string"){ +if(!pattern43.test(data1)){ +const err9 = {instancePath:instancePath+"/publicKey",schemaPath:"#/properties/publicKey/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9+/]{43}=$"},message:"must match pattern \""+"^[A-Za-z0-9+/]{43}=$"+"\""}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +} +else { +const err10 = {instancePath:instancePath+"/publicKey",schemaPath:"#/properties/publicKey/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +if(data.role !== undefined){ +let data2 = data.role; +if(typeof data2 !== "string"){ +const err11 = {instancePath:instancePath+"/role",schemaPath:"#/properties/role/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +} +if(!((data2 === "release") || (data2 === "yank"))){ +const err12 = {instancePath:instancePath+"/role",schemaPath:"#/properties/role/enum",keyword:"enum",params:{allowedValues: schema67.properties.role.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +} +if(data.notBefore !== undefined){ +let data3 = data.notBefore; +if(typeof data3 === "string"){ +if(!(formats0.validate(data3))){ +const err13 = {instancePath:instancePath+"/notBefore",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +} +} +else { +const err14 = {instancePath:instancePath+"/notBefore",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +} +} +if(data.notAfter !== undefined){ +let data4 = data.notAfter; +if(typeof data4 === "string"){ +if(!(formats0.validate(data4))){ +const err15 = {instancePath:instancePath+"/notAfter",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err15]; +} +else { +vErrors.push(err15); +} +errors++; +} +} +else { +const err16 = {instancePath:instancePath+"/notAfter",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err16]; +} +else { +vErrors.push(err16); +} +errors++; +} +} +if(data.status !== undefined){ +let data5 = data.status; +if(typeof data5 !== "string"){ +const err17 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err17]; +} +else { +vErrors.push(err17); +} +errors++; +} +if(!(((data5 === "active") || (data5 === "retiring")) || (data5 === "revoked"))){ +const err18 = {instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: schema67.properties.status.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err18]; +} +else { +vErrors.push(err18); +} +errors++; +} +} +} +else { +const err19 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err19]; +} +else { +vErrors.push(err19); +} +errors++; +} +validate32.errors = vErrors; +return errors === 0; +} +validate32.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate31(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://cloudhal.dev/schemas/v1/release-keys.schema.json" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate31.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.schemaVersion === undefined){ +const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +if(data.format === undefined){ +const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "format"},message:"must have required property '"+"format"+"'"}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +if(data.issuedAt === undefined){ +const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "issuedAt"},message:"must have required property '"+"issuedAt"+"'"}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +if(data.expires === undefined){ +const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "expires"},message:"must have required property '"+"expires"+"'"}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +if(data.signing === undefined){ +const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "signing"},message:"must have required property '"+"signing"+"'"}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +if(data.keys === undefined){ +const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "keys"},message:"must have required property '"+"keys"+"'"}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +for(const key0 in data){ +if(!((((((key0 === "schemaVersion") || (key0 === "format")) || (key0 === "issuedAt")) || (key0 === "expires")) || (key0 === "signing")) || (key0 === "keys"))){ +const err6 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +} +if(data.schemaVersion !== undefined){ +let data0 = data.schemaVersion; +if(!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0)))){ +const err7 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/type",keyword:"type",params:{type: "integer"},message:"must be integer"}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +if(!(data0 === 1)){ +const err8 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/enum",keyword:"enum",params:{allowedValues: schema63.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +if(data.format !== undefined){ +let data1 = data.format; +if(typeof data1 !== "string"){ +const err9 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +if(!(data1 === "cloudhal.release-keys@1")){ +const err10 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/enum",keyword:"enum",params:{allowedValues: schema62.properties.format.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +if(data.issuedAt !== undefined){ +let data2 = data.issuedAt; +if(typeof data2 === "string"){ +if(!(formats0.validate(data2))){ +const err11 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +} +} +else { +const err12 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +} +if(data.expires !== undefined){ +let data3 = data.expires; +if(typeof data3 === "string"){ +if(!(formats0.validate(data3))){ +const err13 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +} +} +else { +const err14 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +} +} +if(data.signing !== undefined){ +let data4 = data.signing; +if(data4 && typeof data4 == "object" && !Array.isArray(data4)){ +if(data4.keyId === undefined){ +const err15 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"}; +if(vErrors === null){ +vErrors = [err15]; +} +else { +vErrors.push(err15); +} +errors++; +} +if(data4.algorithm === undefined){ +const err16 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "algorithm"},message:"must have required property '"+"algorithm"+"'"}; +if(vErrors === null){ +vErrors = [err16]; +} +else { +vErrors.push(err16); +} +errors++; +} +if(data4.signature === undefined){ +const err17 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "signature"},message:"must have required property '"+"signature"+"'"}; +if(vErrors === null){ +vErrors = [err17]; +} +else { +vErrors.push(err17); +} +errors++; +} +for(const key1 in data4){ +if(!(((key1 === "keyId") || (key1 === "algorithm")) || (key1 === "signature"))){ +const err18 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err18]; +} +else { +vErrors.push(err18); +} +errors++; +} +} +if(data4.keyId !== undefined){ +let data5 = data4.keyId; +if(typeof data5 === "string"){ +if(!pattern35.test(data5)){ +const err19 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""}; +if(vErrors === null){ +vErrors = [err19]; +} +else { +vErrors.push(err19); +} +errors++; +} +} +else { +const err20 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err20]; +} +else { +vErrors.push(err20); +} +errors++; +} +} +if(data4.algorithm !== undefined){ +let data6 = data4.algorithm; +if(typeof data6 !== "string"){ +const err21 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err21]; +} +else { +vErrors.push(err21); +} +errors++; +} +if(!(data6 === "Ed25519")){ +const err22 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/enum",keyword:"enum",params:{allowedValues: schema62.properties.signing.properties.algorithm.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err22]; +} +else { +vErrors.push(err22); +} +errors++; +} +} +if(data4.signature !== undefined){ +let data7 = data4.signature; +if(typeof data7 !== "string"){ +const err23 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err23]; +} +else { +vErrors.push(err23); +} +errors++; +} +if(!(data7 === "keys.json.sig")){ +const err24 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/enum",keyword:"enum",params:{allowedValues: schema62.properties.signing.properties.signature.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err24]; +} +else { +vErrors.push(err24); +} +errors++; +} +} +} +else { +const err25 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err25]; +} +else { +vErrors.push(err25); +} +errors++; +} +} +if(data.keys !== undefined){ +let data8 = data.keys; +if(Array.isArray(data8)){ +if(data8.length > 64){ +const err26 = {instancePath:instancePath+"/keys",schemaPath:"#/properties/keys/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"}; +if(vErrors === null){ +vErrors = [err26]; +} +else { +vErrors.push(err26); +} +errors++; +} +const len0 = data8.length; +for(let i0=0; i0 9007199254740991 || isNaN(data2)){ +const err14 = {instancePath:instancePath+"/serial",schemaPath:"#/properties/serial/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9007199254740991},message:"must be <= 9007199254740991"}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +} +if(data2 < 1 || isNaN(data2)){ +const err15 = {instancePath:instancePath+"/serial",schemaPath:"#/properties/serial/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"}; +if(vErrors === null){ +vErrors = [err15]; +} +else { +vErrors.push(err15); +} +errors++; +} +} +} +if(data.issuedAt !== undefined){ +let data3 = data.issuedAt; +if(typeof data3 === "string"){ +if(!(formats0.validate(data3))){ +const err16 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err16]; +} +else { +vErrors.push(err16); +} +errors++; +} +} +else { +const err17 = {instancePath:instancePath+"/issuedAt",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err17]; +} +else { +vErrors.push(err17); +} +errors++; +} +} +if(data.expires !== undefined){ +let data4 = data.expires; +if(typeof data4 === "string"){ +if(!(formats0.validate(data4))){ +const err18 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""}; +if(vErrors === null){ +vErrors = [err18]; +} +else { +vErrors.push(err18); +} +errors++; +} +} +else { +const err19 = {instancePath:instancePath+"/expires",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err19]; +} +else { +vErrors.push(err19); +} +errors++; +} +} +if(data.signing !== undefined){ +let data5 = data.signing; +if(data5 && typeof data5 == "object" && !Array.isArray(data5)){ +if(data5.keyId === undefined){ +const err20 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"}; +if(vErrors === null){ +vErrors = [err20]; +} +else { +vErrors.push(err20); +} +errors++; +} +if(data5.algorithm === undefined){ +const err21 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "algorithm"},message:"must have required property '"+"algorithm"+"'"}; +if(vErrors === null){ +vErrors = [err21]; +} +else { +vErrors.push(err21); +} +errors++; +} +if(data5.signature === undefined){ +const err22 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "signature"},message:"must have required property '"+"signature"+"'"}; +if(vErrors === null){ +vErrors = [err22]; +} +else { +vErrors.push(err22); +} +errors++; +} +for(const key1 in data5){ +if(!(((key1 === "keyId") || (key1 === "algorithm")) || (key1 === "signature"))){ +const err23 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}; +if(vErrors === null){ +vErrors = [err23]; +} +else { +vErrors.push(err23); +} +errors++; +} +} +if(data5.keyId !== undefined){ +let data6 = data5.keyId; +if(typeof data6 === "string"){ +if(!pattern35.test(data6)){ +const err24 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""}; +if(vErrors === null){ +vErrors = [err24]; +} +else { +vErrors.push(err24); +} +errors++; +} +} +else { +const err25 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/$defs/KeyId/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err25]; +} +else { +vErrors.push(err25); +} +errors++; +} +} +if(data5.algorithm !== undefined){ +let data7 = data5.algorithm; +if(typeof data7 !== "string"){ +const err26 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err26]; +} +else { +vErrors.push(err26); +} +errors++; +} +if(!(data7 === "Ed25519")){ +const err27 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/enum",keyword:"enum",params:{allowedValues: schema71.properties.signing.properties.algorithm.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err27]; +} +else { +vErrors.push(err27); +} +errors++; +} +} +if(data5.signature !== undefined){ +let data8 = data5.signature; +if(typeof data8 !== "string"){ +const err28 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err28]; +} +else { +vErrors.push(err28); +} +errors++; +} +if(!(data8 === "yanked.json.sig")){ +const err29 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/enum",keyword:"enum",params:{allowedValues: schema71.properties.signing.properties.signature.enum},message:"must be equal to one of the allowed values"}; +if(vErrors === null){ +vErrors = [err29]; +} +else { +vErrors.push(err29); +} +errors++; +} +} +} +else { +const err30 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err30]; +} +else { +vErrors.push(err30); +} +errors++; +} +} +if(data.versions !== undefined){ +let data9 = data.versions; +if(Array.isArray(data9)){ +if(data9.length > 1000){ +const err31 = {instancePath:instancePath+"/versions",schemaPath:"#/properties/versions/maxItems",keyword:"maxItems",params:{limit: 1000},message:"must NOT have more than 1000 items"}; +if(vErrors === null){ +vErrors = [err31]; +} +else { +vErrors.push(err31); +} +errors++; +} +const len0 = data9.length; +for(let i0=0; i0 500){ +const err40 = {instancePath:instancePath+"/versions/" + i0+"/reason",schemaPath:"#/properties/versions/items/properties/reason/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"}; +if(vErrors === null){ +vErrors = [err40]; +} +else { +vErrors.push(err40); +} +errors++; +} +if(func3(data13) < 1){ +const err41 = {instancePath:instancePath+"/versions/" + i0+"/reason",schemaPath:"#/properties/versions/items/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"}; +if(vErrors === null){ +vErrors = [err41]; +} +else { +vErrors.push(err41); +} +errors++; +} +} +else { +const err42 = {instancePath:instancePath+"/versions/" + i0+"/reason",schemaPath:"#/properties/versions/items/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err42]; +} +else { +vErrors.push(err42); +} +errors++; +} +} +} +else { +const err43 = {instancePath:instancePath+"/versions/" + i0,schemaPath:"#/properties/versions/items/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err43]; +} +else { +vErrors.push(err43); +} +errors++; +} +} +} +else { +const err44 = {instancePath:instancePath+"/versions",schemaPath:"#/properties/versions/type",keyword:"type",params:{type: "array"},message:"must be array"}; +if(vErrors === null){ +vErrors = [err44]; +} +else { +vErrors.push(err44); +} +errors++; +} +} +if(data.revokedKeys !== undefined){ +let data14 = data.revokedKeys; +if(Array.isArray(data14)){ +if(data14.length > 64){ +const err45 = {instancePath:instancePath+"/revokedKeys",schemaPath:"#/properties/revokedKeys/maxItems",keyword:"maxItems",params:{limit: 64},message:"must NOT have more than 64 items"}; +if(vErrors === null){ +vErrors = [err45]; +} +else { +vErrors.push(err45); +} +errors++; +} +const len1 = data14.length; +for(let i1=0; i1 500){ +const err54 = {instancePath:instancePath+"/revokedKeys/" + i1+"/reason",schemaPath:"#/properties/revokedKeys/items/properties/reason/maxLength",keyword:"maxLength",params:{limit: 500},message:"must NOT have more than 500 characters"}; +if(vErrors === null){ +vErrors = [err54]; +} +else { +vErrors.push(err54); +} +errors++; +} +if(func3(data18) < 1){ +const err55 = {instancePath:instancePath+"/revokedKeys/" + i1+"/reason",schemaPath:"#/properties/revokedKeys/items/properties/reason/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"}; +if(vErrors === null){ +vErrors = [err55]; +} +else { +vErrors.push(err55); +} +errors++; +} +} +else { +const err56 = {instancePath:instancePath+"/revokedKeys/" + i1+"/reason",schemaPath:"#/properties/revokedKeys/items/properties/reason/type",keyword:"type",params:{type: "string"},message:"must be string"}; +if(vErrors === null){ +vErrors = [err56]; +} +else { +vErrors.push(err56); +} +errors++; +} +} +} +else { +const err57 = {instancePath:instancePath+"/revokedKeys/" + i1,schemaPath:"#/properties/revokedKeys/items/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err57]; +} +else { +vErrors.push(err57); +} +errors++; +} +} +} +else { +const err58 = {instancePath:instancePath+"/revokedKeys",schemaPath:"#/properties/revokedKeys/type",keyword:"type",params:{type: "array"},message:"must be array"}; +if(vErrors === null){ +vErrors = [err58]; +} +else { +vErrors.push(err58); +} +errors++; +} +} +} +else { +const err59 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err59]; +} +else { +vErrors.push(err59); +} +errors++; +} +validate34.errors = vErrors; +return errors === 0; +} +validate34.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + diff --git a/packages/release-verify/src/index.ts b/packages/release-verify/src/index.ts index 9b3294d3..371670f3 100644 --- a/packages/release-verify/src/index.ts +++ b/packages/release-verify/src/index.ts @@ -7,8 +7,10 @@ export { type IncrementalSha256, incrementalSha256, sha256Hex, verifyEd25519 } from "./crypto.ts"; export type { ListedPart, Worker } from "./documents.ts"; export { ERROR_CODES, ReleaseVerifyError, type ReleaseVerifyErrorCode } from "./errors.ts"; +export type { ListedKey, ReleaseKeys as KeyListDocument } from "./generated/release-keys.ts"; export type { ReleaseManifest } from "./generated/release-manifest.ts"; export type { ReleaseStatement } from "./generated/release-statement.ts"; +export type { ReleaseYankList } from "./generated/release-yank-list.ts"; export { type KeyStatus, PINNED_KEYS, type ReleaseKey, type ReleaseKeys } from "./keys.ts"; export { channelOf, @@ -22,6 +24,20 @@ export { export { foldPath, type PathKind, unsafePathReason } from "./paths.ts"; export { IMPLIED_SCOPES, missingScopes } from "./scopes.ts"; export type { ByteSource } from "./tar.ts"; +export { + addMonths, + KEYS_FILE, + mergeTrust, + PINNED_ROOTS, + type RootKeys, + TRUST_BASE_URL, + type VerifiedKeyList, + type VerifiedYankList, + verifyKeys, + verifyYanked, + YANKED_FILE, + yankOf, +} from "./trust.ts"; export { RELEASE_REPOSITORY, releaseAssetNames, releaseAssetUrls } from "./urls.ts"; export { type VerifiedEntry, @@ -29,3 +45,4 @@ export { type VerifyReleaseInput, verifyRelease, } from "./verify.ts"; +export { rememberYanks, type YankMemory } from "./yank-memory.ts"; diff --git a/packages/release-verify/src/keys.ts b/packages/release-verify/src/keys.ts index d655398f..cb79e09a 100644 --- a/packages/release-verify/src/keys.ts +++ b/packages/release-verify/src/keys.ts @@ -15,6 +15,14 @@ export interface ReleaseKey { /** The raw 32-byte Ed25519 public key, base64. */ readonly publicKey: string; readonly status: KeyStatus; + /** The key signs statements created in `[notBefore, notAfter]` (RFC 3339, UTC). */ + readonly notBefore?: string; + readonly notAfter?: string; + /** + * Set by `mergeTrust` when a fresh, root-signed `keys.json` lists this key (and does not revoke + * it). Only then may a `retiring` key verify after its `notAfter` (RFC-0046 §9). + */ + readonly listed?: boolean; } export type ReleaseKeys = Readonly>; @@ -23,15 +31,17 @@ export const PINNED_KEYS: ReleaseKeys = Object.freeze({ "cloudhal-release-2026a": Object.freeze({ publicKey: "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", status: "active", + notBefore: "2026-11-12T00:00:00Z", + notAfter: "2027-11-12T00:00:00Z", }), }); -/** The raw public key `keyId` verifies with, or a refusal (unknown, revoked, malformed). */ +/** The raw public key `keyId` verifies with and its record, or a refusal (unknown, revoked, malformed). */ export function trustedKey( keys: ReleaseKeys, revoked: readonly string[], keyId: string, -): Uint8Array { +): { raw: Uint8Array; key: ReleaseKey } { const key = Object.hasOwn(keys, keyId) ? keys[keyId] : undefined; if (!key) return refuse("key-unknown", `no trusted release key ${keyId}`); if (key.status === "revoked" || revoked.includes(keyId)) @@ -39,10 +49,30 @@ export function trustedKey( const raw = fromBase64(key.publicKey); if (raw?.length !== 32) return refuse("key-unknown", `the trusted key ${keyId} is not a 32-byte Ed25519 key`); - return raw; + return { raw, key }; } -function fromBase64(s: string): Uint8Array | undefined { +/** + * RFC-0046 §9's expiry rule for the key that signed a statement created at `createdAt`: it must + * lie within the key's `[notBefore, notAfter]`, and after `notAfter` the key verifies only while a + * fresh `keys.json` lists it as `retiring` (`listed`). Pinned keys alone refuse after `notAfter`. + */ +export function checkKeyWindow(keyId: string, key: ReleaseKey, createdAt: string, now: Date): void { + const created = Date.parse(createdAt); + if (key.notBefore !== undefined && created < Date.parse(key.notBefore)) + refuse("key-expired", `${keyId} was not yet valid when this release was created`); + if (key.notAfter === undefined) return; + const notAfter = Date.parse(key.notAfter); + if (created > notAfter) + refuse("key-expired", `${keyId} had expired when this release was created`); + if (now.getTime() > notAfter && !(key.status === "retiring" && key.listed === true)) + refuse( + "key-expired", + `${keyId} expired on ${key.notAfter.slice(0, 10)}; a fresh keys.json must list it as retiring`, + ); +} + +export function fromBase64(s: string): Uint8Array | undefined { try { const bin = atob(s); const out = new Uint8Array(bin.length); diff --git a/packages/release-verify/src/trust.ts b/packages/release-verify/src/trust.ts new file mode 100644 index 00000000..e9d7ce6d --- /dev/null +++ b/packages/release-verify/src/trust.ts @@ -0,0 +1,244 @@ +/** + * The signed key list and yank list (RFC-0046 §9, #226), and how they change what a consumer + * trusts: only ever downwards. + * + * - `verifyKeys`: `keys.json` + `keys.json.sig`, signed by a pinned root, fresh (`issuedAt` ≤ + * now < `expires`, at most 13 months apart). + * - `verifyYanked`: `yanked.json` + `yanked.json.sig`, signed by a yank-role key that a verified + * `keys.json` lists as active and valid now, fresh (`expires` exactly 30 days after `issuedAt`; + * past it the list is stale, which blocks new installs only), and not rolled back below a + * `serial` the consumer has already seen. + * - `mergeTrust`: pinned keys reduced by both: revoke, mark retiring, narrow the validity window. + * A key the lists name but the consumer does not pin is never added. + */ +import { sha256Hex, verifyEd25519 } from "./crypto.ts"; +import { refuse } from "./errors.ts"; +import type { ReleaseKeys as KeyListDocument, ListedKey } from "./generated/release-keys.ts"; +import type { ReleaseYankList } from "./generated/release-yank-list.ts"; +import { validateReleaseKeys, validateReleaseYankList } from "./generated/validators.js"; +import { fromBase64, type ReleaseKey, type ReleaseKeys } from "./keys.ts"; +import { checkKeysMemory, checkYankMemory, type YankMemory } from "./yank-memory.ts"; + +/** Root keys: they sign only `keys.json`. Pinned at the root ceremony; empty until then. */ +export type RootKeys = Readonly>; + +/** + * The offline root's public key (docs/security/release-signing-keys.md, kept equal by + * test/package.test.ts). Empty until the owner's root ceremony: until then `verifyKeys` refuses + * every list unless a caller passes `roots`. + */ +export const PINNED_ROOTS: RootKeys = Object.freeze({}); + +export const KEYS_FILE = "keys.json"; +export const YANKED_FILE = "yanked.json"; +/** Where the lists live (RFC-0046 §9); `.sig` next to each. */ +export const TRUST_BASE_URL = "https://cloudhal.dev/.well-known/cloudhal/"; + +const MAX_KEYS_BYTES = 64 * 1024; +const MAX_YANKED_BYTES = 256 * 1024; +/** A list issued a little in the future is a clock skew, not an attack. */ +const SKEW_MS = 5 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface VerifiedKeyList { + readonly document: KeyListDocument; + /** SHA-256 of the exact bytes, for logs. */ + readonly sha256: string; +} + +export interface VerifiedYankList { + readonly document: ReleaseYankList; + readonly sha256: string; +} + +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** The signed JSON document in `bytes` and its signing key id, before its signature is checked. */ +function signedDoc( + bytes: Uint8Array, + signature: Uint8Array, + max: number, + code: "key-list-invalid" | "yank-list-invalid", +): { doc: unknown; keyId: string } { + const what = code === "key-list-invalid" ? "keys.json" : "yanked.json"; + if (bytes.length > max) refuse(code, `${what} is above ${max} bytes`); + if (signature.length !== 64) refuse(code, `${what}.sig is not 64 bytes`); + let doc: unknown; + try { + doc = JSON.parse(utf8.decode(bytes)); + } catch { + return refuse(code, `${what} is not UTF-8 JSON`); + } + const keyId = (doc as { signing?: { keyId?: unknown } } | null)?.signing?.keyId; + if (typeof keyId !== "string") refuse(code, `${what} names no signing key`); + return { doc, keyId: keyId as string }; +} + +/** `iso` plus `months` calendar months (UTC), clamped to the target month's last day (01-31 + 1 → 02-28/29). */ +export function addMonths(iso: string, months: number): number { + const d = new Date(iso); + const target = new Date( + Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth() + months, + 1, + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds(), + ), + ); + const last = new Date( + Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0), + ).getUTCDate(); + target.setUTCDate(Math.min(d.getUTCDate(), last)); + return target.getTime(); +} + +/** Verifies `keys.json` (see the file comment); throws a `ReleaseVerifyError`. */ +export async function verifyKeys(input: { + document: Uint8Array; + signature: Uint8Array; + roots?: RootKeys; + now?: Date; + /** A consumer's `YankMemory`: a `keys.json` older than the newest one seen is refused. */ + memory?: YankMemory; +}): Promise { + const now = (input.now ?? new Date()).getTime(); + const { doc, keyId } = signedDoc( + input.document, + input.signature, + MAX_KEYS_BYTES, + "key-list-invalid", + ); + const roots = input.roots ?? PINNED_ROOTS; + const root = Object.hasOwn(roots, keyId) ? roots[keyId] : undefined; + const raw = root ? fromBase64(root.publicKey) : undefined; + if (raw?.length !== 32) + refuse("key-list-invalid", `keys.json is signed by ${keyId}, not a pinned root`); + if (!(await verifyEd25519(raw as Uint8Array, input.document, input.signature))) + refuse("key-list-invalid", `keys.json.sig does not verify with ${keyId}`); + if (!validateReleaseKeys(doc)) refuse("key-list-invalid", "keys.json is not a valid key list"); + const list = doc; + const ids = new Set(); + for (const k of list.keys) { + if (ids.has(k.keyId)) refuse("key-list-invalid", `keys.json lists ${k.keyId} twice`); + ids.add(k.keyId); + if (!(Date.parse(k.notBefore) < Date.parse(k.notAfter))) + refuse("key-list-invalid", `${k.keyId}: notBefore is not before notAfter`); + } + // One active yank key at a time: rotation replaces it, so a thief of one key cannot switch to + // another to start a fresh memory (yank-memory.ts). + const activeYank = list.keys.filter((k) => k.role === "yank" && k.status === "active"); + if (activeYank.length > 1) + refuse( + "key-list-invalid", + `keys.json lists ${activeYank.length} active yank keys (${activeYank.map((k) => k.keyId).join(", ")}); at most one`, + ); + const issued = Date.parse(list.issuedAt); + const expires = Date.parse(list.expires); + if (!(issued < expires) || expires > addMonths(list.issuedAt, 13)) + refuse("key-list-invalid", "keys.json must expire after issuedAt and within 13 months"); + if (issued > now + SKEW_MS) refuse("key-list-invalid", "keys.json is issued in the future"); + if (now >= expires) refuse("key-list-expired", `keys.json expired on ${list.expires}`); + checkKeysMemory(list, input.memory); + return { document: list, sha256: await sha256Hex(input.document) }; +} + +/** + * Verifies `yanked.json` against a verified key list (see the file comment). A consumer that can + * keep state passes its `memory` and stores `rememberYanks(result, keys, memory)` (yank-memory.ts): + * a replayed older `keys.json`, a revoked or replaced yank key, a lower serial or a dropped entry + * is refused. A stateless consumer (a Worker) cannot detect a replay or an un-yank: for it, a + * stolen yank key can un-yank and un-revoke until the root rotates the yank key. + */ +export async function verifyYanked(input: { + document: Uint8Array; + signature: Uint8Array; + keys: VerifiedKeyList; + now?: Date; + memory?: YankMemory; +}): Promise { + const now = (input.now ?? new Date()).getTime(); + const { doc, keyId } = signedDoc( + input.document, + input.signature, + MAX_YANKED_BYTES, + "yank-list-invalid", + ); + const signer: ListedKey | undefined = input.keys.document.keys.find((k) => k.keyId === keyId); + if (signer?.role !== "yank" || signer.status !== "active") + refuse("yank-list-invalid", `yanked.json is signed by ${keyId}, not an active yank key`); + const key = signer as ListedKey; + if (now < Date.parse(key.notBefore) || now > Date.parse(key.notAfter)) + refuse("yank-list-invalid", `the yank key ${keyId} is not valid now`); + const raw = fromBase64(key.publicKey) as Uint8Array; + if (!(await verifyEd25519(raw, input.document, input.signature))) + refuse("yank-list-invalid", `yanked.json.sig does not verify with ${keyId}`); + if (!validateReleaseYankList(doc)) + refuse("yank-list-invalid", "yanked.json is not a valid yank list"); + const list = doc; + const issued = Date.parse(list.issuedAt); + if (Date.parse(list.expires) - issued !== 30 * DAY_MS) + refuse("yank-list-invalid", "yanked.json must expire exactly 30 days after issuedAt"); + if (issued > now + SKEW_MS) refuse("yank-list-invalid", "yanked.json is issued in the future"); + checkYankMemory(list, input.keys.document, input.memory); + if (now >= Date.parse(list.expires)) + refuse( + "stale-yank-list", + `yanked.json expired on ${list.expires}; new installs wait for a fresh one`, + ); + return { document: list, sha256: await sha256Hex(input.document) }; +} + +const RANK: Record = { active: 0, retiring: 1, revoked: 2 }; +const later = (a: string | undefined, b: string) => + a === undefined || Date.parse(b) > Date.parse(a) ? b : a; +const earlier = (a: string | undefined, b: string) => + a === undefined || Date.parse(b) < Date.parse(a) ? b : a; + +/** + * `pinned` reduced by a verified key list and yank list (RFC-0046 §9). For each pinned key: a + * listing with another public key or another role revokes it; otherwise the stricter status wins + * and the window narrows to the intersection; `listed` records that a fresh list names it. A + * revocation in either list revokes it. Keys the lists name but `pinned` lacks are not added. + */ +export function mergeTrust( + pinned: ReleaseKeys, + lists: { keys?: VerifiedKeyList; yanked?: VerifiedYankList } = {}, +): ReleaseKeys { + const out: Record = {}; + const revokedByYank = new Set(lists.yanked?.document.revokedKeys.map((r) => r.keyId) ?? []); + for (const [keyId, key] of Object.entries(pinned)) { + let next: ReleaseKey = { ...key }; + const listed = lists.keys?.document.keys.find((k) => k.keyId === keyId); + if (listed) { + const conflict = listed.publicKey !== key.publicKey || listed.role !== "release"; + const status: ReleaseKey["status"] = conflict + ? "revoked" + : RANK[listed.status] > RANK[key.status] + ? listed.status + : key.status; + next = { + ...next, + status, + notBefore: later(key.notBefore, listed.notBefore), + notAfter: earlier(key.notAfter, listed.notAfter), + listed: status !== "revoked", + }; + } else if (next.listed) { + // Only a list verified now may confirm a key; a caller's own flag is not trusted. + next = { ...next, listed: false }; + } + if (revokedByYank.has(keyId)) next = { ...next, status: "revoked", listed: false }; + out[keyId] = Object.freeze(next); + } + return Object.freeze(out); +} + +/** The yank entry for `version`, if the verified list yanks it. */ +export function yankOf( + yanked: VerifiedYankList | undefined, + version: string, +): { reason: string; yankedAt: string } | undefined { + return yanked?.document.versions.find((v) => v.version === version); +} diff --git a/packages/release-verify/src/verify.ts b/packages/release-verify/src/verify.ts index acccc209..0525abc8 100644 --- a/packages/release-verify/src/verify.ts +++ b/packages/release-verify/src/verify.ts @@ -2,7 +2,8 @@ * `verifyRelease` (RFC-0046 §2, §11a): the six steps of §2 in order, with no soft-fail anywhere. * * 1. sizes: the statement at most 4096 bytes, the signature exactly 64; - * 2. Ed25519 over the exact statement bytes with a trusted key for `signing.keyId`; + * 2. Ed25519 over the exact statement bytes with a trusted key for `signing.keyId`, valid at + * the statement's `createdAt` and still trusted now (§9), and the version not yanked; * 3. the statement is for the requested version and channel (invariant 7); * 4. the statement's schema and invariants 1, 2, 3 and 6; * 5. the manifest: size and SHA-256 equal the statement's, schema, invariants 4 and 5, the @@ -28,10 +29,11 @@ import { import { refuse } from "./errors.ts"; import type { ReleaseManifest } from "./generated/release-manifest.ts"; import type { ReleaseStatement } from "./generated/release-statement.ts"; -import { PINNED_KEYS, type ReleaseKeys, trustedKey } from "./keys.ts"; +import { checkKeyWindow, PINNED_KEYS, type ReleaseKeys, trustedKey } from "./keys.ts"; import { LIMITS } from "./limits.ts"; import { checkPath } from "./paths.ts"; import { type ByteSource, chunksOf, gunzip, readTar } from "./tar.ts"; +import { type VerifiedYankList, yankOf } from "./trust.ts"; export interface VerifyReleaseInput { /** What the consumer asked for; `channel` defaults to the one the version implies. */ @@ -48,6 +50,10 @@ export interface VerifyReleaseInput { readonly keys?: ReleaseKeys; /** Key ids to distrust on top of `keys` (reduce-only). */ readonly revoked?: readonly string[]; + /** A verified yank list (`verifyYanked`): a yanked version is refused. Pass `keys` through `mergeTrust` to apply its revocations too. */ + readonly yanked?: VerifiedYankList; + /** The time keys are checked against (default: now). */ + readonly now?: Date; } export interface VerifiedEntry { @@ -78,12 +84,16 @@ export async function verifyRelease(input: VerifyReleaseInput): Promise [...new Set(lists.flat())].sort(); + +/** The memory after accepting `yanked` with `keys` (see the file comment). */ +export function rememberYanks( + yanked: { readonly document: ReleaseYankList }, + keys: { readonly document: KeyListDocument }, + previous?: YankMemory, +): YankMemory { + const d = yanked.document; + const revokedByRoot = keys.document.keys + .filter((k) => k.role === "yank" && k.status === "revoked") + .map((k) => k.keyId); + const replaced = previous && previous.keyId !== d.signing.keyId ? [previous.keyId] : []; + const newerKeys = + previous && Date.parse(previous.keysIssuedAt) > Date.parse(keys.document.issuedAt) + ? previous.keysIssuedAt + : keys.document.issuedAt; + return { + keyId: d.signing.keyId, + serial: d.serial, + versions: d.versions.map((v) => v.version), + revokedKeys: d.revokedKeys.map((k) => k.keyId), + keysIssuedAt: newerKeys, + retiredYankKeys: union(previous?.retiredYankKeys ?? [], revokedByRoot, replaced), + }; +} + +/** Refuses a `keys.json` older than the newest one `memory` has seen (a replayed list). */ +export function checkKeysMemory(keys: KeyListDocument, memory: YankMemory | undefined): void { + if (memory && Date.parse(keys.issuedAt) < Date.parse(memory.keysIssuedAt)) + refuse( + "key-list-invalid", + `keys.json was issued ${keys.issuedAt}, before ${memory.keysIssuedAt} seen before: a replayed key list`, + ); +} + +/** Refuses a yank list (with the key list it verified against) that goes back on `memory`. */ +export function checkYankMemory( + list: ReleaseYankList, + keys: KeyListDocument, + memory: YankMemory | undefined, +): void { + if (!memory) return; + checkKeysMemory(keys, memory); + const signer = list.signing.keyId; + if (memory.retiredYankKeys.includes(signer)) + refuse( + "yank-list-invalid", + `yanked.json is signed by ${signer}, a revoked or replaced yank key`, + ); + if (memory.keyId !== signer) { + const old = keys.keys.find((k) => k.keyId === memory.keyId); + if (!(Date.parse(keys.issuedAt) > Date.parse(memory.keysIssuedAt)) || old?.status === "active") + refuse( + "stale-yank-list", + `yanked.json is signed by ${signer}, not ${memory.keyId}: a new yank key needs a newer keys.json that replaces ${memory.keyId}`, + ); + return; + } + if (list.serial < memory.serial) + refuse( + "stale-yank-list", + `yanked.json has serial ${list.serial}, below ${memory.serial} seen before: a rolled-back list`, + ); + const versions = new Set(list.versions.map((v) => v.version)); + const revoked = new Set(list.revokedKeys.map((k) => k.keyId)); + const dropped = [ + ...memory.versions.filter((v) => !versions.has(v)), + ...memory.revokedKeys.filter((k) => !revoked.has(k)), + ]; + if (dropped.length) + refuse( + "stale-yank-list", + `yanked.json drops ${dropped.join(", ")}, seen before: a yank or revocation is never undone`, + ); +} diff --git a/packages/release-verify/test/helpers.ts b/packages/release-verify/test/helpers.ts index a330e59a..97fb9809 100644 --- a/packages/release-verify/test/helpers.ts +++ b/packages/release-verify/test/helpers.ts @@ -24,6 +24,8 @@ export function realInput(over: Partial = {}): VerifyRelease manifest: fixture("manifest.json"), tarball: () => new Uint8Array(0), planVersion: 1, + // Inside cloudhal-release-2026a's window, so the tests do not age with the calendar. + now: new Date("2026-12-01T00:00:00Z"), ...over, }; } diff --git a/packages/release-verify/test/package.test.ts b/packages/release-verify/test/package.test.ts index b4b742bc..742214a7 100644 --- a/packages/release-verify/test/package.test.ts +++ b/packages/release-verify/test/package.test.ts @@ -16,6 +16,7 @@ import { LIMITS, missingScopes, PINNED_KEYS, + PINNED_ROOTS, releaseAssetNames, releaseAssetUrls, unsafePathReason, @@ -27,22 +28,44 @@ const ROOT = path.resolve(here, "../../.."); const SRC = path.resolve(here, "../src"); describe("the package and its sources of truth", () => { - it("PINNED_KEYS is the key doc's table, row for row", () => { - const doc = readFileSync(path.join(ROOT, "docs/security/release-signing-keys.md"), "utf8"); - const rows = doc + const keyDoc = () => + readFileSync(path.join(ROOT, "docs/security/release-signing-keys.md"), "utf8") .split("\n") .filter((l) => l.startsWith("| `")) .map((l) => l.split("|").map((c) => c.trim())); + const day = (d: string) => `${d}T00:00:00Z`; + + it("PINNED_KEYS is the key doc's release keys, row for row, with their windows", () => { + // | `id` | Ed25519 | `public key` | status | since | not after | + const rows = keyDoc().filter((r) => (r[1] as string).startsWith("`cloudhal-release-")); const fromDoc = Object.fromEntries( rows.map((r) => [ (r[1] as string).slice(1, -1), - { publicKey: (r[3] as string).slice(1, -1), status: r[4] }, + { + publicKey: (r[3] as string).slice(1, -1), + status: r[4], + notBefore: day(r[5] as string), + notAfter: day(r[6] as string), + }, ]), ); expect(fromDoc).toEqual(PINNED_KEYS); expect(Object.isFrozen(PINNED_KEYS)).toBe(true); }); + it("PINNED_ROOTS is the key doc's root keys (none until the root ceremony)", () => { + // | `cloudhal-root-…` | Ed25519 | `public key` | since | + const rows = keyDoc().filter((r) => (r[1] as string).startsWith("`cloudhal-root-")); + const fromDoc = Object.fromEntries( + rows.map((r) => [ + (r[1] as string).slice(1, -1), + { publicKey: (r[3] as string).slice(1, -1) }, + ]), + ); + expect(fromDoc).toEqual(PINNED_ROOTS); + expect(Object.isFrozen(PINNED_ROOTS)).toBe(true); + }); + it("LIMITS and the asset names equal the producer's", () => { expect(LIMITS).toEqual(PRODUCER_LIMITS); expect(releaseAssetNames("0.9.0")).toEqual(producerAssetNames("0.9.0")); diff --git a/packages/release-verify/test/trust.test.ts b/packages/release-verify/test/trust.test.ts new file mode 100644 index 00000000..33c92d04 --- /dev/null +++ b/packages/release-verify/test/trust.test.ts @@ -0,0 +1,549 @@ +/** + * RFC-0046 §9 (#226): the signed key list and yank list, reduce-only merging, the expiry rule + * and yanked versions, with throwaway root, yank and release keys made in the test. + */ +import { generateKeyPairSync, type KeyObject, sign } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { canonicalJson } from "../../../scripts/release-bundle/manifest.ts"; +import { + addMonths, + mergeTrust, + PINNED_KEYS, + type ReleaseKeys, + type ReleaseVerifyErrorCode, + rememberYanks, + verifyKeys, + verifyRelease, + verifyYanked, +} from "../src/index.ts"; +import { realInput, synthetic } from "./helpers.ts"; + +const utf8 = (s: string) => new TextEncoder().encode(s); +function keyPair(): { privateKey: KeyObject; raw: string } { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const der = publicKey.export({ format: "der", type: "spki" }); + return { privateKey, raw: Buffer.from(der.subarray(12)).toString("base64") }; +} +const signed = (doc: unknown, key: KeyObject) => { + const bytes = utf8(canonicalJson(doc)); + return { document: bytes, signature: new Uint8Array(sign(null, bytes, key)) }; +}; +async function codeOf(p: Promise): Promise { + try { + await p; + return "ok"; + } catch (e) { + const code = (e as { code?: ReleaseVerifyErrorCode }).code; + if (!code) throw e; + return code; + } +} + +const ROOT = keyPair(); +const YANK = keyPair(); +const ROOTS = { "test-root-2026a": { publicKey: ROOT.raw } }; +const NOW = new Date("2026-12-01T00:00:00Z"); +const RELEASE_2026A = PINNED_KEYS["cloudhal-release-2026a"] as { publicKey: string }; + +function keysDoc(over: Record = {}) { + return { + schemaVersion: 1, + format: "cloudhal.release-keys@1", + issuedAt: "2026-11-20T00:00:00Z", + expires: "2027-11-20T00:00:00Z", + signing: { keyId: "test-root-2026a", algorithm: "Ed25519", signature: "keys.json.sig" }, + keys: [ + { + keyId: "cloudhal-release-2026a", + publicKey: RELEASE_2026A.publicKey, + role: "release", + notBefore: "2026-11-12T00:00:00Z", + notAfter: "2027-11-12T00:00:00Z", + status: "active", + }, + { + keyId: "test-yank-2026a", + publicKey: YANK.raw, + role: "yank", + notBefore: "2026-11-20T00:00:00Z", + notAfter: "2027-11-20T00:00:00Z", + status: "active", + }, + ], + ...over, + }; +} +function yankDoc(over: Record = {}) { + return { + schemaVersion: 1, + format: "cloudhal.release-yank-list@1", + serial: 5, + issuedAt: "2026-11-25T00:00:00Z", + expires: "2026-12-25T00:00:00Z", + signing: { keyId: "test-yank-2026a", algorithm: "Ed25519", signature: "yanked.json.sig" }, + versions: [], + revokedKeys: [], + ...over, + }; +} +const keysAt = (doc: unknown, now = NOW) => + verifyKeys({ ...signed(doc, ROOT.privateKey), roots: ROOTS, now }); + +describe("verifyKeys", () => { + it("accepts a fresh list signed by a pinned root", async () => { + const list = await keysAt(keysDoc()); + expect(list.document.keys.map((k) => k.keyId)).toEqual([ + "cloudhal-release-2026a", + "test-yank-2026a", + ]); + expect(list.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it("refuses an unpinned root, a wrong signature, tampered bytes and no roots at all", async () => { + const s = signed(keysDoc(), ROOT.privateKey); + expect(await codeOf(verifyKeys({ ...s, now: NOW }))).toBe("key-list-invalid"); // PINNED_ROOTS is empty + const other = signed(keysDoc(), keyPair().privateKey); + expect(await codeOf(verifyKeys({ ...other, roots: ROOTS, now: NOW }))).toBe("key-list-invalid"); + const tampered = new Uint8Array(s.document); + const at = new TextDecoder().decode(tampered).indexOf("2027-11-20"); + tampered[at + 3] = "8".charCodeAt(0); // expires 2028: valid JSON, wrong bytes + expect( + await codeOf( + verifyKeys({ document: tampered, signature: s.signature, roots: ROOTS, now: NOW }), + ), + ).toBe("key-list-invalid"); + expect( + await codeOf( + verifyKeys({ document: s.document, signature: s.signature.subarray(1), roots: ROOTS }), + ), + ).toBe("key-list-invalid"); + }); + + it("refuses an expired list, one issued in the future, one valid for more than 13 months", async () => { + expect(await codeOf(keysAt(keysDoc(), new Date("2027-11-20T00:00:00Z")))).toBe( + "key-list-expired", + ); + expect(await codeOf(keysAt(keysDoc({ issuedAt: "2026-12-02T00:00:00Z" })))).toBe( + "key-list-invalid", + ); + expect(await codeOf(keysAt(keysDoc({ expires: "2027-12-21T00:00:00Z" })))).toBe( + "key-list-invalid", + ); + expect(await codeOf(keysAt(keysDoc({ expires: "2027-12-20T00:00:00Z" })))).toBe("ok"); + }); + + it("refuses a schema violation, a duplicate key id and an empty window", async () => { + expect(await codeOf(keysAt(keysDoc({ extra: 1 })))).toBe("key-list-invalid"); + const doc = keysDoc(); + const first = doc.keys[0] as (typeof doc.keys)[number]; + const twice = { ...doc, keys: [first, first] }; + expect(await codeOf(keysAt(twice))).toBe("key-list-invalid"); + const empty = { ...doc, keys: [{ ...first, notAfter: "2026-11-12T00:00:00Z" }] }; + expect(await codeOf(keysAt(empty))).toBe("key-list-invalid"); + }); + + it("allows at most one active yank key (rotation replaces it, never overlaps)", async () => { + const doc = keysDoc(); + const yank = doc.keys[1] as (typeof doc.keys)[number]; + const second = { ...yank, keyId: "test-yank-2027a", publicKey: keyPair().raw }; + expect(await codeOf(keysAt({ ...doc, keys: [...doc.keys, second] }))).toBe("key-list-invalid"); + const retiring = { ...doc, keys: [...doc.keys, { ...second, status: "retiring" }] }; + expect(await codeOf(keysAt(retiring))).toBe("ok"); + }); +}); + +describe("verifyYanked", () => { + const yankedAt = async (doc: unknown, key = YANK.privateKey, now = NOW, keys = keysDoc()) => + verifyYanked({ ...signed(doc, key), keys: await keysAt(keys), now }); + + it("accepts a fresh list signed by an active yank key the key list names", async () => { + const list = await yankedAt(yankDoc()); + expect(list.document.versions).toEqual([]); + }); + + it("is stale 30 days after issuedAt: new installs wait (stale-yank-list)", async () => { + expect( + await codeOf(yankedAt(yankDoc(), YANK.privateKey, new Date("2026-12-25T00:00:00Z"))), + ).toBe("stale-yank-list"); + }); + + it("a consumer with memory refuses a rollback or a dropped entry (stale-yank-list)", async () => { + const keys = await keysAt(keysDoc()); + const yanks = { + versions: [{ version: "0.9.3", yankedAt: "2026-11-20T00:00:00Z", reason: "Broken." }], + revokedKeys: [ + { keyId: "cloudhal-release-2025a", revokedAt: "2026-11-20T00:00:00Z", reason: "Old." }, + ], + }; + const first = await verifyYanked({ + ...signed(yankDoc(yanks), YANK.privateKey), + keys, + now: NOW, + }); + const memory = rememberYanks(first, keys); + expect(memory).toEqual({ + keyId: "test-yank-2026a", + serial: 5, + versions: ["0.9.3"], + revokedKeys: ["cloudhal-release-2025a"], + keysIssuedAt: "2026-11-20T00:00:00Z", + retiredYankKeys: [], + }); + const later = (over: Record) => + verifyYanked({ + ...signed(yankDoc({ ...yanks, ...over }), YANK.privateKey), + keys, + now: NOW, + memory, + }); + expect(await codeOf(later({ serial: 6 }))).toBe("ok"); + expect(await codeOf(later({ serial: 5 }))).toBe("ok"); + expect(await codeOf(later({ serial: 4 }))).toBe("stale-yank-list"); + // A stolen yank key signing a fresh list that un-yanks or un-revokes: refused. + expect(await codeOf(later({ serial: 6, versions: [] }))).toBe("stale-yank-list"); + expect(await codeOf(later({ serial: 6, revokedKeys: [] }))).toBe("stale-yank-list"); + }); + + describe("key rotation and replayed key lists (YankMemory)", () => { + const NEXT = keyPair(); + const yank2027 = { + keyId: "test-yank-2027a", + publicKey: NEXT.raw, + role: "yank", + notBefore: "2026-11-20T00:00:00Z", + notAfter: "2027-11-20T00:00:00Z", + status: "active", + }; + /** The root's rotation: newer, lists the new yank key, the old one revoked (or `old`). */ + const rotatedDoc = (old: Record = { status: "revoked" }) => { + const doc = keysDoc(); + const [release, yank] = doc.keys as unknown as [ + Record, + Record, + ]; + return { + ...doc, + issuedAt: "2026-11-28T00:00:00Z", + expires: "2027-11-28T00:00:00Z", + keys: [release, { ...yank, ...old }, yank2027], + }; + }; + const by2027 = (over: Record = {}) => + signed( + yankDoc({ + serial: 1, + signing: { keyId: "test-yank-2027a", algorithm: "Ed25519", signature: "yanked.json.sig" }, + ...over, + }), + NEXT.privateKey, + ); + const by2026 = (over: Record = {}) => + signed(yankDoc({ serial: 50, ...over }), YANK.privateKey); + + /** The memory after accepting K1's list (serial 40, 0.9.3 yanked) under the first keys.json. */ + async function rememberedK1() { + const keys = await keysAt(keysDoc()); + const list = await verifyYanked({ + ...signed( + yankDoc({ + serial: 40, + versions: [{ version: "0.9.3", yankedAt: "2026-11-20T00:00:00Z", reason: "Broken." }], + }), + YANK.privateKey, + ), + keys, + now: NOW, + }); + return rememberYanks(list, keys); + } + + it("starts a new memory when a newer keys.json replaces the yank key", async () => { + const memory = await rememberedK1(); + const keys = await keysAt(rotatedDoc()); + const fresh = await verifyYanked({ ...by2027(), keys, now: NOW, memory }); + const next = rememberYanks(fresh, keys, memory); + expect(next).toMatchObject({ + keyId: "test-yank-2027a", + serial: 1, + versions: [], + keysIssuedAt: "2026-11-28T00:00:00Z", + retiredYankKeys: ["test-yank-2026a"], + }); + }); + + it("refuses a replayed older keys.json, with or without a list from the old yank key", async () => { + const memory = await rememberedK1(); + const rotated = await keysAt(rotatedDoc()); + const after = rememberYanks( + await verifyYanked({ ...by2027(), keys: rotated, now: NOW, memory }), + rotated, + memory, + ); + // The attacker supplies the previous, unexpired keys.json (K1 active) and an empty K1 list. + const replayed = await keysAt(keysDoc()); + expect( + await codeOf( + verifyYanked({ ...by2026({ versions: [] }), keys: replayed, now: NOW, memory: after }), + ), + ).toBe("key-list-invalid"); + expect( + await codeOf( + verifyKeys({ + ...signed(keysDoc(), ROOT.privateKey), + roots: ROOTS, + now: NOW, + memory: after, + }), + ), + ).toBe("key-list-invalid"); + }); + + it("refuses a revoked or replaced yank key forever, even under a newer keys.json", async () => { + const memory = await rememberedK1(); + const rotated = await keysAt(rotatedDoc()); + const after = rememberYanks( + await verifyYanked({ ...by2027(), keys: rotated, now: NOW, memory }), + rotated, + memory, + ); + // A later root list that lists K1 active again (with K2 retired) cannot bring K1 back. + const doc = rotatedDoc({ status: "active" }); + const relisted = { + ...doc, + issuedAt: "2026-11-29T00:00:00Z", + keys: [doc.keys[0], doc.keys[1], { ...yank2027, status: "revoked" }], + }; + expect( + await codeOf( + verifyYanked({ ...by2026(), keys: await keysAt(relisted), now: NOW, memory: after }), + ), + ).toBe("yank-list-invalid"); + // Revoked by the root list itself: remembered before any list from the new key arrives. + const revoking = rememberYanks( + await verifyYanked({ ...by2027(), keys: rotated, now: NOW }), + rotated, + ); + expect(revoking.retiredYankKeys).toEqual(["test-yank-2026a"]); + }); + + it("refuses another yank key without a newer keys.json that stops listing the old one as active", async () => { + const memory = await rememberedK1(); + // Same issuedAt as the remembered keys.json: no rotation happened. + const same = { + ...rotatedDoc(), + issuedAt: "2026-11-20T00:00:00Z", + expires: "2027-11-20T00:00:00Z", + }; + expect( + await codeOf(verifyYanked({ ...by2027(), keys: await keysAt(same), now: NOW, memory })), + ).toBe("stale-yank-list"); + }); + }); + + it("addMonths clamps to the last day of the target month", () => { + expect(new Date(addMonths("2027-01-31T10:00:00Z", 13)).toISOString()).toBe( + "2028-02-29T10:00:00.000Z", + ); + expect(new Date(addMonths("2026-01-31T00:00:00Z", 1)).toISOString()).toBe( + "2026-02-28T00:00:00.000Z", + ); + expect(new Date(addMonths("2026-11-20T12:00:00Z", 12)).toISOString()).toBe( + "2027-11-20T12:00:00.000Z", + ); + }); + + it("refuses expires other than 30 days, a non-yank signer, a revoked yank key", async () => { + expect(await codeOf(yankedAt(yankDoc({ expires: "2026-12-26T00:00:00Z" })))).toBe( + "yank-list-invalid", + ); + // Signed by the release key's id: listed, but not a yank key. + const asRelease = yankDoc({ + signing: { + keyId: "cloudhal-release-2026a", + algorithm: "Ed25519", + signature: "yanked.json.sig", + }, + }); + expect(await codeOf(yankedAt(asRelease))).toBe("yank-list-invalid"); + const doc = keysDoc(); + const [release, yank] = doc.keys as [(typeof doc.keys)[number], (typeof doc.keys)[number]]; + const revoked = { ...doc, keys: [release, { ...yank, status: "revoked" }] }; + expect(await codeOf(yankedAt(yankDoc(), YANK.privateKey, NOW, revoked))).toBe( + "yank-list-invalid", + ); + expect(await codeOf(yankedAt(yankDoc(), keyPair().privateKey))).toBe("yank-list-invalid"); + }); +}); + +describe("mergeTrust: reduce-only", () => { + it("revokes, marks retiring, narrows windows; never adds a key", async () => { + const pinned: ReleaseKeys = { + "key-a": { + publicKey: RELEASE_2026A.publicKey, + status: "active", + notBefore: "2026-01-01T00:00:00Z", + notAfter: "2028-01-01T00:00:00Z", + }, + "key-b": { publicKey: YANK.raw, status: "active" }, + "key-c": { publicKey: ROOT.raw, status: "active", listed: true }, + }; + const keys = await keysAt( + keysDoc({ + keys: [ + { + keyId: "key-a", + publicKey: RELEASE_2026A.publicKey, + role: "release", + notBefore: "2026-06-01T00:00:00Z", + notAfter: "2027-06-01T00:00:00Z", + status: "retiring", + }, + // Same id, another public key: a conflict revokes. + { + keyId: "key-b", + publicKey: ROOT.raw, + role: "release", + notBefore: "2026-01-01T00:00:00Z", + notAfter: "2028-01-01T00:00:00Z", + status: "active", + }, + { + keyId: "new-key", + publicKey: ROOT.raw, + role: "release", + notBefore: "2026-01-01T00:00:00Z", + notAfter: "2028-01-01T00:00:00Z", + status: "active", + }, + { + keyId: "test-yank-2026a", + publicKey: YANK.raw, + role: "yank", + notBefore: "2026-11-20T00:00:00Z", + notAfter: "2027-11-20T00:00:00Z", + status: "active", + }, + ], + }), + ); + const merged = mergeTrust(pinned, { keys }); + expect(merged["key-a"]).toEqual({ + publicKey: RELEASE_2026A.publicKey, + status: "retiring", + notBefore: "2026-06-01T00:00:00Z", + notAfter: "2027-06-01T00:00:00Z", + listed: true, + }); + expect(merged["key-b"]?.status).toBe("revoked"); + // `key-c` is not in the list: a caller's own `listed` flag does not survive. + expect(merged["key-c"]).toMatchObject({ status: "active", listed: false }); + expect(Object.keys(merged)).toEqual(["key-a", "key-b", "key-c"]); + // A list can never re-activate: an active listing of a revoked pin stays revoked. + const revokedPin = { ...(pinned["key-a"] as ReleaseKeys[string]), status: "revoked" as const }; + const again = mergeTrust({ "key-a": revokedPin }, { keys }); + expect(again["key-a"]?.status).toBe("revoked"); + }); + + it("a yank list revokes a pinned key", async () => { + const keys = await keysAt(keysDoc()); + const yanked = await verifyYanked({ + ...signed( + yankDoc({ + revokedKeys: [ + { + keyId: "cloudhal-release-2026a", + revokedAt: "2026-11-30T00:00:00Z", + reason: "Exposed.", + }, + ], + }), + YANK.privateKey, + ), + keys, + now: NOW, + }); + const merged = mergeTrust(PINNED_KEYS, { keys, yanked }); + expect(merged["cloudhal-release-2026a"]?.status).toBe("revoked"); + expect(await codeOf(verifyRelease(realInput({ keys: merged })))).toBe("key-revoked"); + }); +}); + +describe("expiry and yanked versions in verifyRelease", () => { + it("the real v0.9.0 statement verifies inside its key's window (steps 1 to 5 pass)", async () => { + expect(await codeOf(verifyRelease(realInput()))).toBe("tarball-mismatch"); + }); + + it("past notAfter, pinned keys alone refuse (key-expired)", async () => { + const late = new Date("2027-12-01T00:00:00Z"); + expect(await codeOf(verifyRelease(realInput({ now: late })))).toBe("key-expired"); + }); + + it("past notAfter, a fresh key list that marks it retiring keeps old statements valid", async () => { + const late = new Date("2027-12-01T00:00:00Z"); + const doc = keysDoc({ issuedAt: "2027-11-15T00:00:00Z", expires: "2028-11-15T00:00:00Z" }); + doc.keys[0] = { ...doc.keys[0], status: "retiring" } as (typeof doc.keys)[0]; + const keys = await keysAt(doc, late); + const merged = mergeTrust(PINNED_KEYS, { keys }); + expect(await codeOf(verifyRelease(realInput({ now: late, keys: merged })))).toBe( + "tarball-mismatch", + ); + // Listed as active instead of retiring: still refused after notAfter. + const active = await keysAt( + keysDoc({ issuedAt: "2027-11-15T00:00:00Z", expires: "2028-11-15T00:00:00Z" }), + late, + ); + expect( + await codeOf( + verifyRelease(realInput({ now: late, keys: mergeTrust(PINNED_KEYS, { keys: active }) })), + ), + ).toBe("key-expired"); + }); + + it("a statement created outside its key's window is refused", async () => { + const window = (notBefore: string, notAfter: string): ReleaseKeys => ({ + "cloudhal-release-2026a": { + ...(PINNED_KEYS["cloudhal-release-2026a"] as ReleaseKeys[string]), + notBefore, + notAfter, + }, + }); + // v0.9.0 was created 2026-11-13T16:13:20Z. + expect( + await codeOf( + verifyRelease(realInput({ keys: window("2026-11-14T00:00:00Z", "2027-11-12T00:00:00Z") })), + ), + ).toBe("key-expired"); + expect( + await codeOf( + verifyRelease( + realInput({ + keys: window("2026-01-01T00:00:00Z", "2026-11-13T00:00:00Z"), + now: new Date("2026-11-12T00:00:00Z"), + }), + ), + ), + ).toBe("key-expired"); + }); + + it("refuses a yanked version, before anything else is read", async () => { + const keys = await keysAt(keysDoc()); + const yanked = await verifyYanked({ + ...signed( + yankDoc({ + versions: [ + { version: "0.9.0", yankedAt: "2026-11-30T00:00:00Z", reason: "Broken migration." }, + ], + }), + YANK.privateKey, + ), + keys, + now: NOW, + }); + const e = await verifyRelease(realInput({ yanked })).catch( + (x: { code: string; message: string }) => x, + ); + expect(e).toMatchObject({ code: "yanked", message: "0.9.0 is yanked: Broken migration." }); + const b = await synthetic(); + expect(await codeOf(verifyRelease({ ...b.input, yanked }))).toBe("yanked"); + }); +}); diff --git a/scripts/publish-ci.ts b/scripts/publish-ci.ts index 6bbde3bd..b91ee8cb 100644 --- a/scripts/publish-ci.ts +++ b/scripts/publish-ci.ts @@ -160,19 +160,32 @@ export async function verifyAsConsumer( dir: string, version: string, publicKeys: Readonly>, + yanked?: unknown, ): Promise { - const { verifyRelease } = await import("../packages/release-verify/src/index.ts"); + const rv = await import("../packages/release-verify/src/index.ts"); const read = (name: string) => readFileSync(path.join(dir, name)); - const release = await verifyRelease({ + // The pinned record (with its window) when the key is the pinned one; a dry run's throwaway key + // has no window. + const keys = Object.fromEntries( + Object.entries(publicKeys).map(([id, publicKey]) => { + const pinned = Object.hasOwn(rv.PINNED_KEYS, id) ? rv.PINNED_KEYS[id] : undefined; + return [ + id, + pinned?.publicKey === publicKey ? pinned : { publicKey, status: "active" as const }, + ]; + }), + ); + const release = await rv.verifyRelease({ request: { version }, statement: read("release-statement.json"), signature: read("release-statement.sig"), manifest: read("manifest.json"), tarball: () => read(`cloudhal-release-${version}.tar.gz`), planVersion: Number.MAX_SAFE_INTEGER, - keys: Object.fromEntries( - Object.entries(publicKeys).map(([id, publicKey]) => [id, { publicKey, status: "active" }]), - ), + keys, + ...(yanked + ? { yanked: yanked as NonNullable[0]["yanked"]> } + : {}), }); for await (const _ of release.entries()) { // every entry verified diff --git a/scripts/release-bundle/verify.ts b/scripts/release-bundle/verify.ts index 82149fed..6615d716 100644 --- a/scripts/release-bundle/verify.ts +++ b/scripts/release-bundle/verify.ts @@ -11,7 +11,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { assertValid, type ReleaseManifest } from "../../packages/core-contracts/src/index.ts"; import { checkManifestDoc, listedParts } from "../../packages/release-verify/src/documents.ts"; -import { verifyRelease } from "../../packages/release-verify/src/index.ts"; +import { PINNED_KEYS, verifyRelease } from "../../packages/release-verify/src/index.ts"; import { verifiedEntries } from "../../packages/release-verify/src/verify.ts"; import { canonicalJson, LIMITS, manifestParts, sha256 } from "./manifest.ts"; import { verifySignature } from "./signature.ts"; @@ -150,11 +150,15 @@ async function verifyAsConsumer( manifest: ReleaseManifest; }, ): Promise { + // The pinned record (with its window) when the key is the pinned one, as a consumer has it. const keys = Object.fromEntries( - Object.entries(o.publicKeys ?? {}).map(([id, publicKey]) => [ - id, - { publicKey, status: "active" as const }, - ]), + Object.entries(o.publicKeys ?? {}).map(([id, publicKey]) => { + const pinned = Object.hasOwn(PINNED_KEYS, id) ? PINNED_KEYS[id] : undefined; + return [ + id, + pinned?.publicKey === publicKey ? pinned : { publicKey, status: "active" as const }, + ]; + }), ); const release = await verifyRelease({ request: { diff --git a/scripts/release-keys.ts b/scripts/release-keys.ts new file mode 100644 index 00000000..af047f1a --- /dev/null +++ b/scripts/release-keys.ts @@ -0,0 +1,446 @@ +/** + * The release trust ceremony (RFC-0046 §9, #226; runbook: docs/security/release-signing-keys.md). + * Node built-ins, plus `@cloudhal/release-verify` (loaded when signing) to check every signed + * document the way consumers do before it is written. No command ever prints a private key. + * + * node scripts/release-keys.ts root-init --key-id cloudhal-root-2026a --out [--passphrase-file ] + * An Ed25519 root: /.key.enc (PKCS#8, AES-256 with the passphrase) + .pub.json. + * node scripts/release-keys.ts yank-init --key-id cloudhal-yank-2026a --out + * The yank key: /.pem (mode 0600, for the RELEASE_YANK_KEY secret) + .pub.json. + * node scripts/release-keys.ts sign-keys --root-key .key.enc --root-pub .pub.json + * [--source security/keys.source.json] [--out security] [--months 12] [--passphrase-file ] + * keys.json + keys.json.sig, signed by the root, expiring after --months (at most 13). + * node scripts/release-keys.ts sign-yanked --key-id [--key-file ] [--list security/yank-list.json] + * [--keys security/keys.json] [--out ] [--published ] [--first-run] + * yanked.json + yanked.json.sig, expiring in 30 days. The key comes from --key-file or the + * RELEASE_YANK_KEY environment variable. It refuses a key that is not the active yank key + * of keys.json, and a list that drops an entry of the published one (append-only). + */ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + type KeyObject, + sign, + verify, +} from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { canonicalJson, sha256 } from "./release-bundle/manifest.ts"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SPKI_ED25519 = Buffer.from("302a300506032b6570032100", "hex"); +const KEY_ID = /^[a-z0-9][a-z0-9-]{2,63}$/; +const DAY_MS = 86_400_000; + +type Log = (line: string) => void; + +export interface PublicKeyFile { + readonly keyId: string; + /** Raw 32-byte Ed25519 public key, base64. */ + readonly publicKey: string; +} + +/** The raw 32-byte public key (base64) of a private or public Ed25519 key. */ +const rawPublicKey = (key: KeyObject): string => { + const pub = key.type === "public" ? key : createPublicKey(key); + const der = pub.export({ format: "der", type: "spki" }); + return Buffer.from(der.subarray(SPKI_ED25519.length)).toString("base64"); +}; + +const publicKeyOf = (raw: string): KeyObject => + createPublicKey({ + key: Buffer.concat([SPKI_ED25519, Buffer.from(raw, "base64")]), + format: "der", + type: "spki", + }); + +/** Whole seconds, `YYYY-MM-DDTHH:MM:SSZ`, as every contract timestamp here. */ +const iso = (ms: number) => + new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(".000Z", "Z"); + +function checkKeyId(keyId: string, role: "root" | "yank"): void { + if (!KEY_ID.test(keyId)) throw new Error(`not a key id: ${keyId}`); + if (!keyId.includes(`-${role}-`)) + throw new Error(`a ${role} key id names its role: cloudhal-${role}-`); +} + +function writeNew(file: string, content: string | Uint8Array, mode = 0o644): void { + if (existsSync(file)) throw new Error(`${file} exists; refusing to overwrite a key`); + writeFileSync(file, content, { mode, flag: "wx" }); +} + +function writePublic(out: string, keyId: string, key: KeyObject, log: Log): PublicKeyFile { + const pub: PublicKeyFile = { keyId, publicKey: rawPublicKey(key) }; + writeNew(path.join(out, `${keyId}.pub.json`), `${JSON.stringify(pub, null, 2)}\n`); + const fingerprint = sha256(Buffer.from(pub.publicKey, "base64")); + log(`${keyId}: public key ${pub.publicKey} (SHA-256 of the raw key ${fingerprint})`); + return pub; +} + +/** An Ed25519 root, its private key written only encrypted with `passphrase`. */ +export function rootInit(o: { + keyId: string; + out: string; + passphrase: string; + log: Log; +}): PublicKeyFile { + checkKeyId(o.keyId, "root"); + if (o.passphrase.length < 12) throw new Error("the passphrase must be at least 12 characters"); + mkdirSync(o.out, { recursive: true }); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const enc = privateKey.export({ + format: "pem", + type: "pkcs8", + cipher: "aes-256-cbc", + passphrase: o.passphrase, + }); + writeNew(path.join(o.out, `${o.keyId}.key.enc`), enc, 0o600); + o.log(`wrote ${o.keyId}.key.enc (encrypted; seal two copies with the passphrase, apart)`); + return writePublic(o.out, o.keyId, publicKey, o.log); +} + +/** The yank key, its PEM written with mode 0600 for the `RELEASE_YANK_KEY` secret. */ +export function yankInit(o: { keyId: string; out: string; log: Log }): PublicKeyFile { + checkKeyId(o.keyId, "yank"); + mkdirSync(o.out, { recursive: true }); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + writeNew( + path.join(o.out, `${o.keyId}.pem`), + privateKey.export({ format: "pem", type: "pkcs8" }), + 0o600, + ); + o.log( + `wrote ${o.keyId}.pem: gh secret set RELEASE_YANK_KEY --env release-yank < ${o.keyId}.pem, then move it offline`, + ); + return writePublic(o.out, o.keyId, publicKey, o.log); +} + +/** `ms` plus `months` calendar months (UTC), clamped to the target month's last day. */ +function addMonths(ms: number, months: number): number { + const d = new Date(ms); + const target = new Date( + Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth() + months, + 1, + d.getUTCHours(), + d.getUTCMinutes(), + d.getUTCSeconds(), + ), + ); + const last = new Date( + Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0), + ).getUTCDate(); + target.setUTCDate(Math.min(d.getUTCDate(), last)); + return target.getTime(); +} + +/** + * The consumer's own verifier, which checks the exact signed bytes before anything is written + * (schema, unique key ids, windows, timestamps, sizes). Needs `@cloudhal/release-verify`'s + * dependencies installed (the workflow installs that one package, without scripts). + */ +async function verifier() { + try { + return await import("../packages/release-verify/src/index.ts"); + } catch (e) { + throw new Error( + `the release verifier is not installed (pnpm install --filter @cloudhal/release-verify): ${(e as Error).message}`, + ); + } +} + +/** Runs `check`; a refusal names what was about to be signed. */ +async function selfCheck(what: string, check: () => Promise): Promise { + try { + await check(); + } catch (e) { + throw new Error(`the signed ${what} does not verify, nothing written: ${(e as Error).message}`); + } +} + +function signDocument(doc: unknown, key: KeyObject, check: KeyObject) { + const bytes = Buffer.from(canonicalJson(doc)); + const signature = sign(null, bytes, key); + if (signature.length !== 64 || !verify(null, bytes, check, signature)) + throw new Error("the signature does not verify with the expected public key"); + return { document: new Uint8Array(bytes), signature: new Uint8Array(signature) }; +} + +/** `keys.json` + `keys.json.sig`, signed by the root; the root key must match `rootPub`. */ +export async function signKeys(o: { + rootKey: string; + passphrase: string; + rootPub: PublicKeyFile; + source: { keys: unknown[] }; + out: string; + now: number; + months?: number; +}): Promise<{ document: Uint8Array; signature: Uint8Array }> { + const months = o.months ?? 12; + if (!(months >= 1 && months <= 13)) throw new Error("keys.json is valid for 1 to 13 months"); + checkKeyId(o.rootPub.keyId, "root"); + let key: KeyObject; + try { + key = createPrivateKey({ key: o.rootKey, passphrase: o.passphrase }); + } catch { + throw new Error("the root key does not open with this passphrase"); + } + if (rawPublicKey(key) !== o.rootPub.publicKey) + throw new Error(`the root key is not ${o.rootPub.keyId}`); + if (!Array.isArray(o.source.keys) || o.source.keys.length === 0) + throw new Error("the source lists no keys"); + const issued = Math.floor(o.now / 1000) * 1000; + const doc = { + schemaVersion: 1, + format: "cloudhal.release-keys@1", + issuedAt: iso(issued), + expires: iso(addMonths(issued, months)), + signing: { keyId: o.rootPub.keyId, algorithm: "Ed25519", signature: "keys.json.sig" }, + keys: o.source.keys, + }; + const signed = signDocument(doc, key, publicKeyOf(o.rootPub.publicKey)); + const rv = await verifier(); + await selfCheck("key list", () => + rv.verifyKeys({ + ...signed, + roots: { [o.rootPub.keyId]: { publicKey: o.rootPub.publicKey } }, + now: new Date(issued), + }), + ); + mkdirSync(o.out, { recursive: true }); + writeFileSync(path.join(o.out, "keys.json"), signed.document); + writeFileSync(path.join(o.out, "keys.json.sig"), signed.signature); + return signed; +} + +interface YankSource { + versions: { version: string; yankedAt: string; reason: string }[]; + revokedKeys: { keyId: string; revokedAt: string; reason: string }[]; +} + +/** The published list: its entries, its signer, and the serial the next one must exceed. */ +interface PublishedYankList extends YankSource { + serial: number; + signing?: { keyId?: string }; +} + +/** Refuses a source that drops an entry of the published list (the list is append-only). */ +export function checkAppendOnly(source: YankSource, published: YankSource | undefined): void { + if (!published) return; + const versions = new Set(source.versions.map((v) => v.version)); + for (const v of published.versions) + if (!versions.has(v.version)) + throw new Error(`the yank list drops ${v.version}; a yank is never undone`); + const keys = new Set(source.revokedKeys.map((k) => k.keyId)); + for (const k of published.revokedKeys) + if (!keys.has(k.keyId)) + throw new Error( + `the yank list drops the revoked key ${k.keyId}; a revocation is never undone`, + ); +} + +/** `yanked.json` + `yanked.json.sig`, signed by the yank key that `keys` lists as active. */ +export async function signYanked(o: { + keyId: string; + keyPem: string; + /** `keys.json` and its signature, verified here against `roots` (default: the pinned root). */ + keyList: { document: Uint8Array; signature: Uint8Array }; + roots?: Readonly>; + source: YankSource; + published: PublishedYankList | undefined; + out: string; + now: number; +}): Promise<{ document: Uint8Array; signature: Uint8Array }> { + if (o.published && !(Number.isSafeInteger(o.published.serial) && o.published.serial >= 1)) + throw new Error("the published yank list has no serial"); + const rv = await verifier(); + const keys = await rv.verifyKeys({ + ...o.keyList, + ...(o.roots ? { roots: o.roots } : {}), + now: new Date(o.now), + }); + checkKeyId(o.keyId, "yank"); + const listed = keys.document.keys.find((k) => k.keyId === o.keyId); + if (listed?.role !== "yank" || listed.status !== "active") + throw new Error(`keys.json does not list ${o.keyId} as an active yank key`); + if (o.now < Date.parse(listed.notBefore) || o.now > Date.parse(listed.notAfter)) + throw new Error(`${o.keyId} is outside its window`); + let key: KeyObject; + try { + key = createPrivateKey(o.keyPem); + } catch { + throw new Error("the yank key is missing or not a PKCS#8 PEM key"); + } + if (rawPublicKey(key) !== listed.publicKey) + throw new Error(`the yank key given is not ${o.keyId} of keys.json`); + if (!Array.isArray(o.source.versions) || !Array.isArray(o.source.revokedKeys)) + throw new Error("the yank list source needs versions and revokedKeys"); + checkAppendOnly(o.source, o.published); + const issued = Math.floor(o.now / 1000) * 1000; + const doc = { + schemaVersion: 1, + format: "cloudhal.release-yank-list@1", + // One more than the published list's, so a consumer that keeps state refuses a rollback. + serial: (o.published?.serial ?? 0) + 1, + issuedAt: iso(issued), + expires: iso(issued + 30 * DAY_MS), + signing: { keyId: o.keyId, algorithm: "Ed25519", signature: "yanked.json.sig" }, + versions: o.source.versions, + revokedKeys: o.source.revokedKeys, + }; + const signed = signDocument(doc, key, publicKeyOf(listed.publicKey)); + // The published list is the memory this one must keep (serial, every entry), as consumers do. + // After a rotation it was signed by the old yank key under an earlier keys.json than this one; + // which one is not known here, so the memory names none (the epoch): the current keys.json is + // then the newer one, as it is for every consumer that kept that earlier one. Its entries are + // still kept, by checkAppendOnly above. + const publishedBy = o.published?.signing?.keyId ?? o.keyId; + const memory = o.published + ? { + keyId: publishedBy, + serial: o.published.serial, + versions: o.published.versions.map((v) => v.version), + revokedKeys: o.published.revokedKeys.map((k) => k.keyId), + keysIssuedAt: publishedBy === o.keyId ? keys.document.issuedAt : "1970-01-01T00:00:00Z", + retiredYankKeys: [], + } + : undefined; + await selfCheck("yank list", () => + rv.verifyYanked({ ...signed, keys, now: new Date(issued), ...(memory ? { memory } : {}) }), + ); + mkdirSync(o.out, { recursive: true }); + writeFileSync(path.join(o.out, "yanked.json"), signed.document); + writeFileSync(path.join(o.out, "yanked.json.sig"), signed.signature); + return signed; +} + +const readJson = (file: string): T => JSON.parse(readFileSync(file, "utf8")) as T; + +/** The published yank list at `from` (a file or an https URL), or undefined when there is none yet. */ +async function published( + from: string | undefined, + firstRun: boolean, +): Promise { + if (!from) { + if (firstRun) return undefined; + throw new Error("--published is required (or --first-run for the very first list)"); + } + if (from.startsWith("https://")) { + const res = await fetch(from); + if (res.status === 404 && firstRun) return undefined; + if (!res.ok) throw new Error(`could not read the published yank list (${res.status})`); + return (await res.json()) as PublishedYankList; + } + if (!existsSync(from) && firstRun) return undefined; + return readJson(from); +} + +/** A passphrase from a file, or typed twice on the terminal without echo. */ +async function passphrase(file: string | undefined, twice: boolean): Promise { + if (file) return readFileSync(file, "utf8").replace(/\r?\n$/, ""); + if (!process.stdin.isTTY) throw new Error("no terminal: pass --passphrase-file"); + const ask = (prompt: string) => + new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const out = rl as unknown as { _writeToOutput: (s: string) => void }; + out._writeToOutput = (s: string) => { + if (s.startsWith(prompt)) process.stdout.write(prompt); + }; + rl.question(prompt, (answer) => { + rl.close(); + process.stdout.write("\n"); + resolve(answer); + }); + }); + const first = await ask("Root passphrase: "); + if (twice && (await ask("Again: ")) !== first) throw new Error("the passphrases differ"); + return first; +} + +export async function main(argv: readonly string[], log: Log = console.log): Promise { + const [command, ...rest] = argv; + const { values: v } = parseArgs({ + args: rest, + options: { + "key-id": { type: "string" }, + out: { type: "string" }, + "passphrase-file": { type: "string" }, + "root-key": { type: "string" }, + "root-pub": { type: "string" }, + source: { type: "string" }, + months: { type: "string" }, + "key-file": { type: "string" }, + list: { type: "string" }, + keys: { type: "string" }, + published: { type: "string" }, + "first-run": { type: "boolean" }, + }, + }); + const need = (name: keyof typeof v) => { + const value = v[name]; + if (typeof value !== "string") throw new Error(`--${name} is required`); + return value; + }; + const security = path.join(ROOT, "security"); + if (command === "root-init") { + const pass = await passphrase(v["passphrase-file"], true); + rootInit({ keyId: need("key-id"), out: need("out"), passphrase: pass, log }); + } else if (command === "yank-init") { + yankInit({ keyId: need("key-id"), out: need("out"), log }); + } else if (command === "sign-keys") { + const pass = await passphrase(v["passphrase-file"], false); + const out = v.out ?? security; + await signKeys({ + rootKey: readFileSync(need("root-key"), "utf8"), + passphrase: pass, + rootPub: readJson(need("root-pub")), + source: readJson<{ keys: unknown[] }>(v.source ?? path.join(security, "keys.source.json")), + out, + now: Date.now(), + ...(v.months ? { months: Number(v.months) } : {}), + }); + log(`signed ${path.join(out, "keys.json")} and keys.json.sig`); + } else if (command === "sign-yanked") { + const keyPem = v["key-file"] + ? readFileSync(v["key-file"], "utf8") + : (process.env.RELEASE_YANK_KEY ?? ""); + delete process.env.RELEASE_YANK_KEY; + const out = v.out ?? path.join(ROOT, ".build/yank-list"); + const keysFile = v.keys ?? path.join(security, "keys.json"); + await signYanked({ + keyId: need("key-id"), + keyPem, + keyList: { document: readFileSync(keysFile), signature: readFileSync(`${keysFile}.sig`) }, + // Default: the pinned root. --root-pub only before the root is pinned (or in tests). + ...(v["root-pub"] + ? (() => { + const pub = readJson(v["root-pub"] as string); + return { roots: { [pub.keyId]: { publicKey: pub.publicKey } } }; + })() + : {}), + source: readJson(v.list ?? path.join(security, "yank-list.json")), + published: await published(v.published, v["first-run"] === true), + out, + now: Date.now(), + }); + log(`signed ${path.join(out, "yanked.json")} and yanked.json.sig`); + } else { + throw new Error( + "usage: node scripts/release-keys.ts root-init|yank-init|sign-keys|sign-yanked …", + ); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await main(process.argv.slice(2)); + } catch (e) { + console.error(`release-keys: ${(e as Error).message}`); + process.exitCode = 1; + } +} diff --git a/security/README.md b/security/README.md new file mode 100644 index 00000000..7c052a9a --- /dev/null +++ b/security/README.md @@ -0,0 +1,11 @@ +# Release trust sources + +The committed sources of Cloudhal's signed key list and yank list ([RFC-0046](../docs/rfcs/0046-release-bundles.md) §9). The runbook is [docs/security/release-signing-keys.md](../docs/security/release-signing-keys.md). + +| File | What it is | Who changes it | +|---|---|---| +| `keys.source.json` | The release and yank keys, with their windows and statuses, that `keys.json` lists | A reviewed pull request, re-signed with the offline root (`node scripts/release-keys.ts sign-keys`) | +| `keys.json`, `keys.json.sig` | The root-signed key list, served at `https://cloudhal.dev/.well-known/cloudhal/keys.json` | `sign-keys` only; never edited by hand | +| `yank-list.json` | Yanked versions and revoked release key ids. **Append-only**: an entry is never removed | A reviewed pull request; the weekly *Yank list* workflow signs it into `yanked.json` | + +`keys.json` does not exist until the owner's root ceremony (the runbook's *First-time setup*). diff --git a/security/keys.source.json b/security/keys.source.json new file mode 100644 index 00000000..4a5ac19b --- /dev/null +++ b/security/keys.source.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "notBefore": "2026-11-12T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" + } + ] +} diff --git a/security/yank-list.json b/security/yank-list.json new file mode 100644 index 00000000..9754311c --- /dev/null +++ b/security/yank-list.json @@ -0,0 +1,4 @@ +{ + "versions": [], + "revokedKeys": [] +}