feat(extensions): 在线安装必须使用已持久确认的来源密钥

This commit is contained in:
2026-09-08 21:37:13 +08:00
parent e1040d3427
commit ee59a6ea9e
2 changed files with 242 additions and 7 deletions
+233 -7
View File
@@ -51,6 +51,39 @@ pub struct PreparedPackage {
pub directory: String,
pub tree_sha256: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TrustSetting {
pub source: String,
pub source_id: String,
pub namespace: String,
pub key_id: String,
pub public_key: [u8; 32],
pub enabled: bool,
}
impl TrustSetting {
pub fn fingerprint(&self) -> Result<String> {
if source(&self.source)? != self.source
|| self.source_id.is_empty()
|| self.source_id.len() > 128
|| self.source_id.chars().any(char::is_control)
|| self.namespace.len() < 2
|| self.namespace.len() > 64
|| !self
.namespace
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|| !self.namespace.as_bytes()[0].is_ascii_alphanumeric()
|| self.key_id.is_empty()
|| self.key_id.len() > 128
|| self.key_id.chars().any(char::is_control)
|| ed25519_dalek::VerifyingKey::from_bytes(&self.public_key).is_err()
{
return Err(HostError::new("EXTENSION_TRUST_INVALID"));
}
Ok(hash(&serde_json::to_vec(self).unwrap()))
}
}
pub struct ExtensionStore {
root: PathBuf,
db: Connection,
@@ -239,10 +272,10 @@ impl ExtensionStore {
let mut db = Connection::open(database)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 3 {
if version > 4 {
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
}
if (1..3).contains(&version) {
if (1..4).contains(&version) {
let backup = root.join(format!(
"extensions.schema{version}.{}.sqlite3",
Uuid::new_v4()
@@ -256,7 +289,8 @@ impl ExtensionStore {
CREATE TABLE IF NOT EXISTS prepared_packages (package_key TEXT PRIMARY KEY REFERENCES versions(package_key),directory TEXT NOT NULL UNIQUE,tree_sha256 TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT);
CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);
PRAGMA user_version=3; COMMIT;")?;
CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id));
PRAGMA user_version=4; COMMIT;")?;
crate::extension_transaction::recover(&mut db)?;
Ok(Self {
root,
@@ -264,13 +298,69 @@ impl ExtensionStore {
_lock: lock,
})
}
/// Online installation gate. The source identity and staged signing key must
/// originate from Host trust settings; this never accepts a replacement key.
pub fn trust_setting(
&self,
source_url: &str,
namespace: &str,
key_id: &str,
) -> Result<Option<TrustSetting>> {
let row:Option<(String,String)>=self.db.query_row("SELECT setting,revision FROM extension_trust WHERE source=?1 AND namespace=?2 AND key_id=?3",
params![source(source_url)?,namespace,key_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
row.map(|(json, revision)| {
let setting: TrustSetting = serde_json::from_str(&json)
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
if setting.fingerprint()? != revision
|| setting.source != source(source_url)?
|| setting.namespace != namespace
|| setting.key_id != key_id
{
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
}
Ok(setting)
})
.transpose()
}
/// Called after the user confirms the displayed fingerprint. A concurrent
/// setting change requires a fresh review; refresh never calls this method.
pub fn confirm_trust(
&mut self,
setting: &TrustSetting,
expected_revision: Option<&str>,
confirmed_fingerprint: &str,
) -> Result<String> {
let revision = setting.fingerprint()?;
if confirmed_fingerprint != revision {
return Err(HostError::new("EXTENSION_TRUST_CONFIRMATION"));
}
let old = self.trust_setting(&setting.source, &setting.namespace, &setting.key_id)?;
let old_revision = old.as_ref().map(TrustSetting::fingerprint).transpose()?;
if old.as_ref() == Some(setting) {
return Ok(revision);
}
if old_revision.as_deref() != expected_revision {
return Err(HostError::new("EXTENSION_TRUST_CONFLICT"));
}
// Prevent one canonical URL from silently acquiring a second source identity.
let mut statement = self
.db
.prepare("SELECT setting FROM extension_trust WHERE source=?1")?;
for row in statement.query_map([&setting.source], |r| r.get::<_, String>(0))? {
let peer: TrustSetting = serde_json::from_str(&row?)
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
if peer.source_id != setting.source_id {
return Err(HostError::new("EXTENSION_TRUST_CHANGED"));
}
}
drop(statement);
self.db.execute("INSERT INTO extension_trust VALUES (?1,?2,?3,?4,?5) ON CONFLICT(source,namespace,key_id) DO UPDATE SET setting=excluded.setting,revision=excluded.revision",
params![setting.source,setting.namespace,setting.key_id,serde_json::to_string(setting).unwrap(),revision])?;
Ok(revision)
}
/// Online installation gate, using confirmed Host trust settings only.
pub async fn switch_online(
&mut self,
operation: &str,
vault_id: &str,
source_id: &str,
changes: &[crate::extension_transaction::Change],
) -> Result<crate::extension_transaction::Receipt> {
if changes.is_empty() || changes.len() > 200 {
@@ -293,11 +383,17 @@ impl ExtensionStore {
let key: [u8; 32] = key
.try_into()
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
let trusted = self
.trust_setting(&source, &release.namespace, &release.key_id)?
.ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?;
if !trusted.enabled || trusted.public_key != key {
return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED"));
}
let client = crate::extension_trust::Client::new(&source)?;
let checked = client
.check(
crate::extension_trust::Pin {
source_id,
source_id: &trusted.source_id,
key_id: &release.key_id,
namespace: &release.namespace,
public_key: &key,
@@ -682,6 +778,136 @@ mod tests {
archive,
}
}
#[tokio::test]
async fn online_switch_never_trusts_the_staged_signer_implicitly() {
let temp = tempfile::tempdir().unwrap();
let (release, archive, key) = fixture();
let mut store = ExtensionStore::open(temp.path()).unwrap();
let staged = store
.stage(request(
&Uuid::new_v4().to_string(),
&release,
&archive,
&key,
))
.unwrap();
let changes = vec![crate::extension_transaction::Change {
target: crate::extension_transaction::Target {
slot: "a".repeat(64),
package_key: staged.package_key,
directory: Uuid::new_v4().to_string(),
tree_sha256: "a".repeat(64),
configuration: serde_json::json!({}),
},
expected_revision: None,
}];
let id = Uuid::new_v4().to_string();
let vault = Uuid::new_v4().to_string();
assert_eq!(
store
.switch_online(&id, &vault, &changes)
.await
.unwrap_err()
.code,
"EXTENSION_SOURCE_UNTRUSTED"
);
let mut setting = TrustSetting {
source: "https://catalog.example/".into(),
source_id: "catalog".into(),
namespace: "examples".into(),
key_id: "test-key".into(),
public_key: key,
enabled: false,
};
let disabled = setting.fingerprint().unwrap();
store.confirm_trust(&setting, None, &disabled).unwrap();
assert_eq!(
store
.switch_online(&id, &vault, &changes)
.await
.unwrap_err()
.code,
"EXTENSION_SOURCE_UNTRUSTED"
);
setting.enabled = true;
setting.public_key = SigningKey::from_bytes(&[8; 32]).verifying_key().to_bytes();
store
.confirm_trust(&setting, Some(&disabled), &setting.fingerprint().unwrap())
.unwrap();
assert_eq!(
store
.switch_online(&id, &vault, &changes)
.await
.unwrap_err()
.code,
"EXTENSION_SOURCE_UNTRUSTED"
);
assert_eq!(
store
.db
.query_row("SELECT COUNT(*) FROM extension_transactions", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
}
#[test]
fn confirmed_trust_survives_reopen_and_rotation_requires_matching_review() {
let temp = tempfile::tempdir().unwrap();
let (_, _, key) = fixture();
let mut store = ExtensionStore::open(temp.path()).unwrap();
let mut setting = TrustSetting {
source: "https://catalog.example/".into(),
source_id: "catalog".into(),
namespace: "examples".into(),
key_id: "test-key".into(),
public_key: key,
enabled: true,
};
let fingerprint = setting.fingerprint().unwrap();
assert!(store
.trust_setting(&setting.source, &setting.namespace, &setting.key_id)
.unwrap()
.is_none());
assert!(store
.confirm_trust(&setting, None, "not-confirmed")
.is_err());
store.confirm_trust(&setting, None, &fingerprint).unwrap();
drop(store);
let mut store = ExtensionStore::open(temp.path()).unwrap();
assert_eq!(
store
.trust_setting(&setting.source, &setting.namespace, &setting.key_id)
.unwrap(),
Some(setting.clone())
);
setting.public_key = SigningKey::from_bytes(&[8; 32]).verifying_key().to_bytes();
let rotated = setting.fingerprint().unwrap();
assert!(store.confirm_trust(&setting, None, &rotated).is_err());
assert!(store
.confirm_trust(&setting, Some(&fingerprint), &fingerprint)
.is_err());
store
.confirm_trust(&setting, Some(&fingerprint), &rotated)
.unwrap();
setting.enabled = false;
let disabled = setting.fingerprint().unwrap();
store
.confirm_trust(&setting, Some(&rotated), &disabled)
.unwrap();
assert!(
!store
.trust_setting(&setting.source, &setting.namespace, &setting.key_id)
.unwrap()
.unwrap()
.enabled
);
setting.key_id = "another-key".into();
setting.source_id = "different".into();
assert!(store
.confirm_trust(&setting, None, &setting.fingerprint().unwrap())
.is_err());
}
#[test]
fn prepared_switch_rechecks_content_vault_binding_and_recovers_on_open() {
use crate::extension_transaction::{Change, Target};