diff --git a/crates/credentials-core/src/admin_ops.rs b/crates/credentials-core/src/admin_ops.rs index 9f1f49b..d2035ff 100644 --- a/crates/credentials-core/src/admin_ops.rs +++ b/crates/credentials-core/src/admin_ops.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; use crate::audit::AuditRecord; use crate::audit::{AuditCtx, AuditOp}; -use crate::record::VaultRecord; +use crate::record::{RecordIdentity, VaultRecord}; use crate::store::{mint_handle, EncryptedStore, GrantOperation, StoreOpError}; /// The admin-op schema version. Bumped only on a breaking op-body change; the @@ -38,6 +38,24 @@ pub enum AdminOpBody { audit_op: AdminAuditOp, mode: StoreMode, }, + /// Identity-aware replacement has its own op discriminator so a daemon that predates + /// sticky identity rejects it instead of silently treating it as a legacy replace. + #[serde(rename = "admin.store_with_identity_policy")] + StoreWithIdentityPolicy { + v: u32, + id: String, + record: Box, + audit_op: AdminAuditOp, + clear_identity: bool, + }, + /// Update only the non-secret identity attached to an existing record. The store + /// re-seals the unchanged credential material and keeps its lifecycle state. + #[serde(rename = "admin.set_identity")] + SetIdentity { + v: u32, + id: String, + identity: RecordIdentity, + }, #[serde(rename = "admin.invalidate")] Invalidate { v: u32, id: String }, /// Reversibly stop serving a credential because an operator intentionally retired @@ -152,6 +170,26 @@ impl std::fmt::Debug for AdminOpBody { .field("audit_op", audit_op) .field("mode", mode) .finish(), + AdminOpBody::StoreWithIdentityPolicy { + v, + id, + record, + audit_op, + clear_identity, + } => f + .debug_struct("StoreWithIdentityPolicy") + .field("v", v) + .field("id", id) + .field("record", record) + .field("audit_op", audit_op) + .field("clear_identity", clear_identity) + .finish(), + AdminOpBody::SetIdentity { v, id, identity } => f + .debug_struct("SetIdentity") + .field("v", v) + .field("id", id) + .field("identity", identity) + .finish(), AdminOpBody::Invalidate { v, id } => f .debug_struct("Invalidate") .field("v", v) @@ -234,6 +272,8 @@ impl AdminOpBody { pub fn schema_version(&self) -> u32 { match self { AdminOpBody::Store { v, .. } + | AdminOpBody::StoreWithIdentityPolicy { v, .. } + | AdminOpBody::SetIdentity { v, .. } | AdminOpBody::Invalidate { v, .. } | AdminOpBody::Logout { v, .. } | AdminOpBody::Reactivate { v, .. } @@ -260,6 +300,8 @@ impl AdminOpBody { credential_id: id, .. } | AdminOpBody::Store { id, .. } + | AdminOpBody::StoreWithIdentityPolicy { id, .. } + | AdminOpBody::SetIdentity { id, .. } | AdminOpBody::Invalidate { id, .. } | AdminOpBody::Logout { id, .. } | AdminOpBody::Reactivate { id, .. } @@ -365,6 +407,29 @@ pub fn apply( } Ok(serde_json::json!({ "stored": true })) } + AdminOpBody::StoreWithIdentityPolicy { + id, + record, + audit_op, + clear_identity, + .. + } => { + store.overwrite_unconditional_with_identity_policy_audited( + &id, + &record, + !clear_identity, + AuditCtx::route_admin(audit_op.to_audit_op(), actor), + )?; + Ok(serde_json::json!({ "stored": true })) + } + AdminOpBody::SetIdentity { id, identity, .. } => { + store.set_identity_audited( + &id, + identity, + AuditCtx::route_admin(AuditOp::SetIdentity, actor), + )?; + Ok(serde_json::json!({ "identity_updated": true })) + } AdminOpBody::Invalidate { id, .. } => { let ctx = AuditCtx::route_admin(AuditOp::Invalidate, actor); let outcome = store.invalidate_and_revoke_all_audited(&id, ctx)?; @@ -541,8 +606,8 @@ fn decode_hash32(s: &str) -> Option<[u8; 32]> { pub enum StoreMode { /// Create-only: fails if the id already exists. Create, - /// Unconditional overwrite (version-guarded internally): the re-login / re-import - /// replace that keeps the handle. + /// Legacy unconditional overwrite. Its serialized shape remains frozen for old + /// clients; identity-aware replacements use `admin.store_with_identity_policy`. ReplaceUnconditional, /// CAS overwrite gated on the current payload hash (lowercase hex). ReplaceCas { expected_hash_hex: String }, @@ -639,4 +704,31 @@ mod tests { let s = String::from_utf8(op.to_bytes().unwrap()).unwrap(); assert!(s.contains("\"op\":\"admin.invalidate\"")); } + + #[test] + fn legacy_unconditional_store_bytes_remain_compatible() { + let op = AdminOpBody::Store { + v: 1, + id: "apikey:x".into(), + record: Box::new(VaultRecord::new_static( + CredentialKind::ApiKey, + "t", + b"k".to_vec(), + None, + )), + audit_op: AdminAuditOp::Put, + mode: StoreMode::ReplaceUnconditional, + }; + assert_eq!( + String::from_utf8(op.to_bytes().unwrap()).unwrap(), + "{\"op\":\"admin.store\",\"v\":1,\"id\":\"apikey:x\",\"record\":{\"schema_version\":1,\"kind\":\"api_key\",\"source\":\"t\",\"record_version\":1,\"expires_at_ms\":null,\"refresh_adapter\":null,\"oauth\":null,\"payload\":[107]},\"audit_op\":\"put\",\"mode\":{\"kind\":\"replace_unconditional\"}}" + ); + } + + #[test] + fn identity_policy_store_op_round_trips() { + let raw = b"{\"op\":\"admin.store_with_identity_policy\",\"v\":1,\"id\":\"apikey:x\",\"record\":{\"schema_version\":1,\"kind\":\"api_key\",\"source\":\"t\",\"record_version\":1,\"expires_at_ms\":null,\"refresh_adapter\":null,\"oauth\":null,\"payload\":[107]},\"audit_op\":\"put\",\"clear_identity\":false}"; + let op: AdminOpBody = serde_json::from_slice(raw).expect("new policy op decodes"); + assert_eq!(op.to_bytes().unwrap(), raw); + } } diff --git a/crates/credentials-core/src/audit.rs b/crates/credentials-core/src/audit.rs index 72954bf..75b3c56 100644 --- a/crates/credentials-core/src/audit.rs +++ b/crates/credentials-core/src/audit.rs @@ -38,6 +38,9 @@ pub enum AuditOp { Login, /// An overwrite under a compare-and-set. Overwrite, + /// A non-secret account identity update that re-seals the existing credential + /// material without replacing it. + SetIdentity, /// An authoritative invalidate (revoke). Invalidate, /// A master-key rotation (rewrap). @@ -100,6 +103,7 @@ impl AuditOp { AuditOp::Import => "import", AuditOp::Login => "login", AuditOp::Overwrite => "overwrite", + AuditOp::SetIdentity => "set_identity", AuditOp::Invalidate => "invalidate", AuditOp::RotateMasterKey => "rotate_master_key", AuditOp::RefreshCommit => "refresh_commit", @@ -580,6 +584,7 @@ mod vocabulary_documentation_tests { AuditOp::Import => AuditOp::Import.as_str(), AuditOp::Login => AuditOp::Login.as_str(), AuditOp::Overwrite => AuditOp::Overwrite.as_str(), + AuditOp::SetIdentity => AuditOp::SetIdentity.as_str(), AuditOp::Invalidate => AuditOp::Invalidate.as_str(), AuditOp::RotateMasterKey => AuditOp::RotateMasterKey.as_str(), AuditOp::RefreshCommit => AuditOp::RefreshCommit.as_str(), @@ -599,6 +604,7 @@ mod vocabulary_documentation_tests { assert_documented(section, "audit_log.op", value(AuditOp::Import)); assert_documented(section, "audit_log.op", value(AuditOp::Login)); assert_documented(section, "audit_log.op", value(AuditOp::Overwrite)); + assert_documented(section, "audit_log.op", value(AuditOp::SetIdentity)); assert_documented(section, "audit_log.op", value(AuditOp::Invalidate)); assert_documented(section, "audit_log.op", value(AuditOp::RotateMasterKey)); assert_documented(section, "audit_log.op", value(AuditOp::RefreshCommit)); diff --git a/crates/credentials-core/src/record.rs b/crates/credentials-core/src/record.rs index 9eb5be9..8c86ef6 100644 --- a/crates/credentials-core/src/record.rs +++ b/crates/credentials-core/src/record.rs @@ -136,6 +136,40 @@ pub struct RecordIdentity { } impl RecordIdentity { + /// Validate the bounded non-secret labels before a store write. Keeping this at + /// the record boundary prevents authenticated admin-op bytes from bypassing CLI + /// validation and making account grouping depend on malformed metadata. + pub fn validate(&self) -> Result<(), String> { + if let Some(account_id) = self.account_id.as_deref() { + if account_id.trim().is_empty() { + return Err("account_id must not be empty".to_string()); + } + validate_identity_value("account_id", account_id)?; + } + if let Some(email) = self.email.as_deref() { + validate_identity_value("email", email)?; + } + if let Some(org_name) = self.org_name.as_deref() { + validate_identity_value("org_name", org_name)?; + } + Ok(()) + } + + /// Drop email-only identity before validation or persistence; consumers join on + /// account_id, so keeping its display-only counterpart would advertise a label + /// that cannot identify an account. + pub(crate) fn normalized(self) -> Self { + if self.is_servable() { + self + } else { + RecordIdentity { + account_id: None, + email: None, + org_name: self.org_name, + } + } + } + /// Whether this identity can be served without collapsing a consumer's labelling. /// /// False for the one shape that looks captured and behaves as though it was not: @@ -153,6 +187,19 @@ impl RecordIdentity { } } +fn validate_identity_value(field: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{field} must not be empty")); + } + if value.len() > 256 { + return Err(format!("{field} must be at most 256 bytes")); + } + if value.chars().any(char::is_control) { + return Err(format!("{field} must not contain control characters")); + } + Ok(()) +} + /// The vault's typed, at-rest view of one credential. Encrypted as one unit; only /// `payload` is ever returned to a consumer. #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -279,15 +326,7 @@ impl VaultRecord { /// Normalising beats refusing: an identity is display metadata, and failing a /// login over it would trade a labelling gap for a lost credential. pub fn with_identity(mut self, identity: RecordIdentity) -> Self { - self.identity = if identity.is_servable() { - identity - } else { - RecordIdentity { - account_id: None, - email: None, - org_name: identity.org_name, - } - }; + self.identity = identity.normalized(); self } @@ -563,4 +602,31 @@ mod tests { "\"cookie\"" ); } + + #[test] + fn identity_validation_rejects_whitespace_only_optional_labels() { + for (field, identity) in [ + ( + "email", + RecordIdentity { + account_id: Some("acct".into()), + email: Some(" \t ".into()), + org_name: None, + }, + ), + ( + "org_name", + RecordIdentity { + account_id: Some("acct".into()), + email: None, + org_name: Some(" ".into()), + }, + ), + ] { + assert!( + identity.validate().is_err(), + "whitespace-only {field} must be rejected" + ); + } + } } diff --git a/crates/credentials-core/src/store.rs b/crates/credentials-core/src/store.rs index d948855..85fffba 100644 --- a/crates/credentials-core/src/store.rs +++ b/crates/credentials-core/src/store.rs @@ -386,6 +386,20 @@ pub enum StoreOpError { AlreadyExists, /// A CAS overwrite's `expected_payload_hash` did not match the current record. CasMismatch, + /// Identity preservation would attach an existing account label to incoming material + /// whose provider claim names a different account. + AccountIdentityMismatch { + credential_id: String, + retained_account_id: String, + incoming_account_id: String, + }, + /// An explicit supplied account label contradicts the provider claim in incoming + /// material. The token claim is authoritative for adapters that expose one. + SuppliedIdentityContradictsClaim { + credential_id: String, + supplied_account_id: String, + derived_account_id: String, + }, /// The record is quarantined (`corrupt`) and cannot be served. Quarantined, /// The record is `needs_reauth` and must not be served until re-authenticated. @@ -409,6 +423,15 @@ pub enum StoreOpError { Store(String), } +enum IdentityPolicyOverwriteOutcome { + NotFound, + AccountMismatch { + retained_account_id: String, + incoming_account_id: String, + }, + Updated(usize), +} + impl std::fmt::Display for StoreOpError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -419,6 +442,22 @@ impl std::fmt::Display for StoreOpError { StoreOpError::CasMismatch => { f.write_str("compare-and-set failed: expected payload hash did not match") } + StoreOpError::AccountIdentityMismatch { + credential_id, + retained_account_id, + incoming_account_id, + } => write!( + f, + "incoming material names account '{incoming_account_id}', but identity preservation would retain account '{retained_account_id}'; pass `--account-id ` with '{incoming_account_id}' or `--clear-identity`; or afterwards: ck auth set-identity {credential_id} --account-id {incoming_account_id}" + ), + StoreOpError::SuppliedIdentityContradictsClaim { + supplied_account_id, + derived_account_id, + .. + } => write!( + f, + "supplied identity names account '{supplied_account_id}', but incoming material names account '{derived_account_id}'; drop `--account-id`, or fix the export; the token's own claim is authoritative" + ), StoreOpError::Quarantined => f.write_str("credential is quarantined (corrupt)"), StoreOpError::NeedsReauth => f.write_str("credential needs re-authentication"), StoreOpError::Decrypt(e) => write!(f, "envelope decrypt failed: {e}"), @@ -454,6 +493,22 @@ impl From for StoreOpError { } } +fn normalize_record_identity(mut record: VaultRecord) -> VaultRecord { + let identity = record.identity.clone(); + record = record.with_identity(identity); + record +} + +fn validate_record_identity(record: &VaultRecord) -> Result<(), StoreOpError> { + record.identity.validate().map_err(StoreOpError::Encode) +} + +fn derived_account_id(record: &VaultRecord) -> Option { + let adapter = record.refresh_adapter.as_deref()?; + let access_token = record.oauth.as_ref()?.access_token.as_str(); + crate::oauth_login::account_id_for_adapter(adapter, access_token) +} + /// SHA-256 of a record's opaque payload — the value an overwrite CAS compares /// against. Computed over the payload bytes only (not the whole record), so a /// caller can prove "I am overwriting the payload I last saw" without holding the @@ -931,7 +986,21 @@ impl EncryptedStore { record: &VaultRecord, ctx: AuditCtx<'_>, ) -> Result<(), StoreOpError> { - let mut record = record.clone(); + let mut record = normalize_record_identity(record.clone()); + if let Some(derived_account_id) = derived_account_id(&record) { + match record.identity.account_id.as_deref() { + Some(supplied_account_id) if supplied_account_id != derived_account_id => { + return Err(StoreOpError::SuppliedIdentityContradictsClaim { + credential_id: credential_id.to_string(), + supplied_account_id: supplied_account_id.to_string(), + derived_account_id, + }); + } + None => record.identity.account_id = Some(derived_account_id), + Some(_) => {} + } + } + validate_record_identity(&record)?; record.record_version = 1; let blob = self.seal_record(credential_id, &record)?; let key_id_hex = self.key_id.to_hex(); @@ -1025,7 +1094,8 @@ impl EncryptedStore { return Err(StoreOpError::CasMismatch); } let next_version = current.record_version.saturating_add(1); - let mut record = record.clone(); + let mut record = normalize_record_identity(record.clone()); + validate_record_identity(&record)?; record.record_version = next_version; let blob = self.seal_record(credential_id, &record)?; let key_id_hex = self.key_id.to_hex(); @@ -1076,10 +1146,35 @@ impl EncryptedStore { Ok(()) } + /// Overwrite an existing record UNCONDITIONALLY (no CAS), re-sealing it at + /// `current_version + 1` and resetting its state to `active`, while preserving an + /// existing identity when the replacement does not provide one. This is the normal + /// token-rotation path; callers that intentionally clear identity use + /// [`Self::overwrite_unconditional_with_identity_policy_audited`]. + pub fn overwrite_unconditional_audited( + &self, + credential_id: &str, + record: &VaultRecord, + ctx: AuditCtx<'_>, + ) -> Result<(), StoreOpError> { + self.overwrite_unconditional_with_identity_policy_audited(credential_id, record, true, ctx) + } + /// Overwrite an existing record UNCONDITIONALLY (no CAS), re-sealing it at /// `current_version + 1` and resetting its state to `active`, with an explicit /// audit context. Fails [`StoreOpError::NotFound`] if the id is absent. /// + /// When `preserve_existing_identity` is true and `record` has no identity, carries + /// the existing identity into the replacement. Identity describes the account rather + /// than the token, so re-importing rotated tokens must not erase account labelling. + /// This preservation assumes the incoming token belongs to the SAME ACCOUNT as the + /// retained label; when the adapter can derive both account ids, a mismatch is refused. + /// A derivation result of `None` preserves the label: an undecodable token cannot + /// contradict it, while refusing would block every non-JWT OpenAI credential. + /// An undecryptable existing envelope is treated as no identity so repair still + /// replaces corrupted material. Passing false makes an empty incoming identity an + /// explicit clear. + /// /// Unlike [`overwrite_cas_audited`], this reads the current version via `meta` /// (plaintext columns, NO decrypt) rather than `get`, so it works even when the /// current record is `needs_reauth` or quarantined — which is exactly the @@ -1088,12 +1183,19 @@ impl EncryptedStore { /// one. The handles table is untouched, so existing handles keep resolving to this /// id (no re-mint). The version bump + state reset + intent clear + audit entry all /// commit in ONE fenced transaction. - pub fn overwrite_unconditional_audited( + pub fn overwrite_unconditional_with_identity_policy_audited( &self, credential_id: &str, record: &VaultRecord, + preserve_existing_identity: bool, ctx: AuditCtx<'_>, ) -> Result<(), StoreOpError> { + let incoming = normalize_record_identity(record.clone()); + validate_record_identity(&incoming)?; + let incoming_account_id = preserve_existing_identity + .then_some(()) + .filter(|_| incoming.identity.is_empty()) + .and_then(|_| derived_account_id(&incoming)); let key_id_hex = self.key_id.to_hex(); let now = now_ms(); let audit_key = self.audit_key.clone(); @@ -1111,19 +1213,48 @@ impl EncryptedStore { // concurrent delete), which cannot happen under the single writer, so the // guard is belt-and-suspenders that also documents the invariant. let outcome = self.fenced_write(|tx| { - let current_version: Option = tx + let existing: Option<(i64, Vec)> = tx .query_row( - "SELECT record_version FROM credentials WHERE credential_id = ?1", + "SELECT record_version, envelope FROM credentials WHERE credential_id = ?1", rusqlite::params![credential_id], - |r| r.get(0), + |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; - let Some(current_version) = current_version else { - return Ok(None); // NotFound: signalled to the caller below. + let Some((current_version, existing_envelope)) = existing else { + return Ok(IdentityPolicyOverwriteOutcome::NotFound); }; let next_version = (current_version as u64).saturating_add(1); - let mut sealed = record.clone(); + let mut sealed = incoming.clone(); + if preserve_existing_identity && sealed.identity.is_empty() { + let existing_identity = envelope::open( + &self.key, + &existing_envelope, + &RecordBinding { + credential_id, + record_version: current_version as u64, + }, + ) + .ok() + .and_then(|plaintext| VaultRecord::decode(&plaintext).ok()) + .map(|existing| existing.identity); + if let Some(identity) = + existing_identity.filter(|identity| identity.validate().is_ok()) + { + let identity = identity.normalized(); + if let (Some(retained_account_id), Some(incoming_account_id)) = + (identity.account_id.as_ref(), incoming_account_id.as_ref()) + { + if retained_account_id != incoming_account_id { + return Ok(IdentityPolicyOverwriteOutcome::AccountMismatch { + retained_account_id: retained_account_id.clone(), + incoming_account_id: incoming_account_id.clone(), + }); + } + } + sealed.identity = identity; + } + } sealed.record_version = next_version; // seal_record is pure crypto (no DB), safe to call inside the txn. let blob = self @@ -1158,8 +1289,114 @@ impl EncryptedStore { }, )?; } - Ok(Some(n)) + Ok(IdentityPolicyOverwriteOutcome::Updated(n)) })?; + match outcome { + IdentityPolicyOverwriteOutcome::NotFound => Err(StoreOpError::NotFound), + IdentityPolicyOverwriteOutcome::AccountMismatch { + retained_account_id, + incoming_account_id, + } => Err(StoreOpError::AccountIdentityMismatch { + credential_id: credential_id.to_string(), + retained_account_id, + incoming_account_id, + }), + IdentityPolicyOverwriteOutcome::Updated(0) => Err(StoreOpError::CasMismatch), + IdentityPolicyOverwriteOutcome::Updated(_) => Ok(()), + } + } + + /// Replace only non-secret account identity, preserving every secret field and the + /// record's lifecycle state. The envelope must be re-sealed, so `record_version` + /// advances and lets consumers treat the identity update as fresh record metadata. + /// This opens the envelope without the normal serving-state gate: identity is safe + /// to correct on `needs_reauth` or `retired` records, but an undecryptable record + /// cannot be labelled because there is no trustworthy material to preserve. + pub fn set_identity_audited( + &self, + credential_id: &str, + identity: crate::record::RecordIdentity, + ctx: AuditCtx<'_>, + ) -> Result<(), StoreOpError> { + let identity = identity.normalized(); + identity.validate().map_err(StoreOpError::Encode)?; + let key_id_hex = self.key_id.to_hex(); + let now = now_ms(); + let audit_key = self.audit_key.clone(); + let typed_error = std::cell::RefCell::new(None); + let outcome = self.fenced_write(|tx| { + let existing: Option<(i64, Vec)> = tx + .query_row( + "SELECT record_version, envelope FROM credentials WHERE credential_id = ?1", + rusqlite::params![credential_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + let Some((current_version, envelope)) = existing else { + return Ok(None); + }; + let plaintext = match envelope::open( + &self.key, + &envelope, + &RecordBinding { + credential_id, + record_version: current_version as u64, + }, + ) { + Ok(plaintext) => plaintext, + Err(error) => { + *typed_error.borrow_mut() = Some(StoreOpError::Decrypt(error)); + return Err(rusqlite::Error::InvalidQuery); + } + }; + let record = match VaultRecord::decode(&plaintext) { + Ok(record) => record, + Err(error) => { + *typed_error.borrow_mut() = Some(StoreOpError::Corrupt(error.to_string())); + return Err(rusqlite::Error::InvalidQuery); + } + }; + let next_version = (current_version as u64).saturating_add(1); + let mut updated = record.with_identity(identity.clone()); + updated.record_version = next_version; + let payload_hash_hex = hex32(&payload_hash(&updated.payload)); + let blob = self + .seal_record(credential_id, &updated) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let changed = tx.execute( + "UPDATE credentials SET record_version = ?2, key_id = ?3, envelope = ?4, \ + updated_at_ms = ?5 WHERE credential_id = ?1 AND record_version = ?6", + rusqlite::params![ + credential_id, + next_version as i64, + key_id_hex, + blob, + now, + current_version, + ], + )?; + if changed > 0 { + append_audit_tx( + tx, + &audit_key, + &AuditRecord { + op: AuditOp::SetIdentity, + credential_id: Some(credential_id.to_string()), + payload_hash: Some(payload_hash_hex), + actor: ctx.actor.to_string(), + alarm: ctx.alarm, + }, + )?; + } + Ok(Some(changed)) + }); + let outcome = match outcome { + Ok(outcome) => outcome, + Err(error) => match typed_error.into_inner() { + Some(typed) => return Err(typed), + None => return Err(StoreOpError::from(error)), + }, + }; match outcome { None => Err(StoreOpError::NotFound), Some(0) => Err(StoreOpError::CasMismatch), @@ -3603,12 +3840,38 @@ mod tests { expires_at_ms: Some(9_999), token_url: "https://t.test/token".into(), client_id: Some("c".into()), - scopes: vec![], + scopes: vec!["scope-a".into(), "scope-b".into()], }, b"payload-bytes".to_vec(), ) } + fn openai_record(account_id: &str, payload: &[u8]) -> VaultRecord { + use base64::Engine as _; + + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(br#"{"alg":"none","typ":"JWT"}"#); + let claims = serde_json::json!({ + "https://api.openai.com/auth": { "chatgpt_account_id": account_id }, + }); + let claims = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes()); + let access_token = format!("{header}.{claims}.sig"); + VaultRecord::new_oauth( + "opencode", + "openai", + OAuthCredential { + access_token, + refresh_token: format!("refresh-{account_id}"), + expires_at_ms: Some(9_999), + token_url: "https://t.test/token".into(), + client_id: Some("c".into()), + scopes: vec!["scope-a".into(), "scope-b".into()], + }, + payload.to_vec(), + ) + } + /// The usable-scan reads a REAL sealed store and reaches its stranded arm. /// /// `is_serviceable` is unit-tested on hand-built credentials, which proves the @@ -3688,6 +3951,145 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn create_accepts_a_supplied_openai_identity_when_the_claim_agrees() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(60); + let record = openai_record("acct-same", b"token").with_identity(RecordIdentity { + account_id: Some("acct-same".to_string()), + email: Some("same@example.com".to_string()), + org_name: None, + }); + + store + .create("oauth:openai", &record) + .expect("matching identity creates"); + + assert_eq!( + store + .get("oauth:openai") + .expect("read created record") + .identity, + record.identity + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn create_refuses_a_supplied_openai_identity_when_the_claim_differs_without_writing() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(61); + let record = openai_record("acct-derived", b"token").with_identity(RecordIdentity { + account_id: Some("acct-supplied".to_string()), + email: Some("supplied@example.com".to_string()), + org_name: None, + }); + let tip_before = store.audit_tip().expect("read initial audit tip"); + + let err = store + .create("oauth:openai", &record) + .expect_err("a supplied identity that contradicts the token claim must refuse"); + + assert!(matches!( + err, + StoreOpError::SuppliedIdentityContradictsClaim { + ref credential_id, + ref supplied_account_id, + ref derived_account_id, + } if credential_id == "oauth:openai" + && supplied_account_id == "acct-supplied" + && derived_account_id == "acct-derived" + )); + assert!( + err.to_string().contains("drop `--account-id`") + && err + .to_string() + .contains("the token's own claim is authoritative"), + "{err}" + ); + assert!(matches!( + store.get("oauth:openai"), + Err(StoreOpError::NotFound) + )); + assert_eq!( + store.audit_tip().expect("read audit tip after refusal"), + tip_before, + "a refused create must not advance the audit chain" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn create_accepts_a_supplied_identity_for_an_adapter_without_a_claim() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(62); + let record = oauth_record().with_identity(RecordIdentity { + account_id: Some("acct-anthropic".to_string()), + email: Some("anthropic@example.com".to_string()), + org_name: None, + }); + + store + .create("oauth:anthropic", &record) + .expect("non-derivable adapter keeps supplied identity behavior"); + assert_eq!( + store + .get("oauth:anthropic") + .expect("read created record") + .identity, + record.identity + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn create_attaches_a_derived_openai_identity_when_none_is_supplied() { + let (root, store) = tmp_store(63); + store + .create("oauth:openai", &openai_record("acct-derived", b"token")) + .expect("create with a derivable claim"); + + assert_eq!( + store + .get("oauth:openai") + .expect("read created record") + .identity + .account_id + .as_deref(), + Some("acct-derived") + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn create_refuses_a_control_character_in_a_derived_openai_identity_without_writing() { + let (root, store) = tmp_store(64); + let record = openai_record("acct-\u{0007}", b"token"); + let tip_before = store.audit_tip().expect("read initial audit tip"); + + let err = store + .create("oauth:openai", &record) + .expect_err("a derived identity must be validated before it is sealed"); + + assert!( + matches!(err, StoreOpError::Encode(ref message) if message.contains("control")), + "expected identity validation error, got {err}" + ); + assert!(matches!( + store.get("oauth:openai"), + Err(StoreOpError::NotFound) + )); + assert_eq!( + store.audit_tip().expect("read audit tip after refusal"), + tip_before, + "a refused create must not advance the audit chain" + ); + let _ = std::fs::remove_dir_all(&root); + } + /// `retire_and_revoke_all_audited` is the compound behind `ck auth logout`: /// mark `retired`, clear any dangling intent, and revoke EVERY live handle, all in /// one fenced transaction with both audit entries inside it. @@ -3943,6 +4345,545 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn unconditional_overwrite_preserves_existing_identity_when_openai_claim_agrees() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(24); + let existing = openai_record("acct-original", b"old-token").with_identity(RecordIdentity { + account_id: Some("acct-original".to_string()), + email: Some("original@example.com".to_string()), + org_name: Some("Original Organization".to_string()), + }); + store.create("oauth:openai", &existing).expect("create"); + + store + .overwrite_unconditional_audited( + "oauth:openai", + &openai_record("acct-original", b"rotated-token"), + AuditCtx::admin(AuditOp::Import), + ) + .expect("replace"); + + assert_eq!( + store.get("oauth:openai").expect("read").identity, + existing.identity, + "a token-only re-import must retain the account identity already attached to the id" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unconditional_overwrite_refuses_to_preserve_identity_for_a_different_openai_account() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(57); + let existing = openai_record("acct-retained", b"old-token").with_identity(RecordIdentity { + account_id: Some("acct-retained".to_string()), + email: Some("retained@example.com".to_string()), + org_name: Some("Retained Organization".to_string()), + }); + store.create("oauth:openai", &existing).expect("create"); + let before = store.get("oauth:openai").expect("read before replace"); + + let err = store + .overwrite_unconditional_audited( + "oauth:openai", + &openai_record("acct-incoming", b"new-token"), + AuditCtx::admin(AuditOp::Import), + ) + .expect_err("a different incoming account must be refused"); + + let message = err.to_string(); + assert!(message.contains("acct-retained"), "{message}"); + assert!(message.contains("acct-incoming"), "{message}"); + assert!(message.contains("--account-id "), "{message}"); + assert!(message.contains("--clear-identity"), "{message}"); + + match err { + StoreOpError::AccountIdentityMismatch { + credential_id, + retained_account_id, + incoming_account_id, + } => { + assert_eq!(credential_id, "oauth:openai"); + assert_eq!(retained_account_id, "acct-retained"); + assert_eq!(incoming_account_id, "acct-incoming"); + } + other => panic!("expected typed account mismatch, got {other}"), + } + let after = store.get("oauth:openai").expect("read after refusal"); + assert_eq!( + after.record_version, before.record_version, + "version changed" + ); + assert_eq!(after.payload, before.payload, "token bytes changed"); + assert_eq!(after.oauth, before.oauth, "OAuth material changed"); + assert_eq!(after.identity, before.identity, "identity changed"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unconditional_overwrite_preserves_identity_when_adapter_cannot_derive_an_account() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(58); + let existing = oauth_record().with_identity(RecordIdentity { + account_id: Some("acct-retained".to_string()), + email: Some("retained@example.com".to_string()), + org_name: None, + }); + store.create("oauth:anthropic", &existing).expect("create"); + + store + .overwrite_unconditional_audited( + "oauth:anthropic", + &oauth_record(), + AuditCtx::admin(AuditOp::Import), + ) + .expect("an adapter without a live account claim keeps prior behavior"); + + assert_eq!( + store.get("oauth:anthropic").expect("read").identity, + existing.identity + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unconditional_overwrite_preserves_identity_when_openai_account_derivation_fails() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(59); + let existing = openai_record("acct-retained", b"old-token").with_identity(RecordIdentity { + account_id: Some("acct-retained".to_string()), + email: Some("retained@example.com".to_string()), + org_name: None, + }); + store.create("oauth:openai", &existing).expect("create"); + let mut incoming = openai_record("unused", b"opaque-new-token"); + incoming.oauth.as_mut().expect("OAuth").access_token = "not-a-jwt".to_string(); + + store + .overwrite_unconditional_audited( + "oauth:openai", + &incoming, + AuditCtx::admin(AuditOp::Import), + ) + .expect("an undecodable incoming token cannot contradict the retained label"); + + let after = store.get("oauth:openai").expect("read replacement"); + assert_eq!(after.payload, b"opaque-new-token"); + assert_eq!(after.identity, existing.identity); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unconditional_overwrite_uses_explicit_identity_for_a_different_openai_account() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(25); + let existing = openai_record("acct-original", b"old-token").with_identity(RecordIdentity { + account_id: Some("acct-original".to_string()), + email: Some("original@example.com".to_string()), + org_name: None, + }); + let incoming = + openai_record("acct-replacement", b"new-token").with_identity(RecordIdentity { + account_id: Some("acct-replacement".to_string()), + email: Some("replacement@example.com".to_string()), + org_name: Some("Replacement Organization".to_string()), + }); + store.create("oauth:openai", &existing).expect("create"); + + store + .overwrite_unconditional_audited( + "oauth:openai", + &incoming, + AuditCtx::admin(AuditOp::Import), + ) + .expect("replace"); + + assert_eq!( + store.get("oauth:openai").expect("read").identity, + incoming.identity, + "explicit import identity must replace stale account metadata" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unconditional_overwrite_keeps_an_identity_empty_when_both_records_lack_one() { + let (root, store) = tmp_store(26); + store + .create("oauth:anthropic", &oauth_record()) + .expect("create"); + + store + .overwrite_unconditional_audited( + "oauth:anthropic", + &oauth_record(), + AuditCtx::admin(AuditOp::Import), + ) + .expect("replace"); + + assert!( + store + .get("oauth:anthropic") + .expect("read") + .identity + .is_empty(), + "a replacement cannot synthesize identity where neither record supplied it" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unconditional_overwrite_can_clear_identity_for_a_different_openai_account() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(27); + let existing = openai_record("acct-original", b"old-token").with_identity(RecordIdentity { + account_id: Some("acct-original".to_string()), + email: Some("original@example.com".to_string()), + org_name: None, + }); + store.create("oauth:openai", &existing).expect("create"); + + store + .overwrite_unconditional_with_identity_policy_audited( + "oauth:openai", + &openai_record("acct-replacement", b"new-token"), + false, + AuditCtx::admin(AuditOp::Import), + ) + .expect("replace with clear"); + + assert!( + store.get("oauth:openai").expect("read").identity.is_empty(), + "an explicit clear must not be mistaken for token-only replace preservation" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unconditional_overwrite_repairs_a_corrupt_record_without_inventing_identity() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(28); + let existing = oauth_record().with_identity(RecordIdentity { + account_id: Some("acct-original".to_string()), + email: Some("original@example.com".to_string()), + org_name: None, + }); + store.create("oauth:anthropic", &existing).expect("create"); + store + .with_raw_conn(|conn| { + conn.execute( + "UPDATE credentials SET envelope = X'00' WHERE credential_id = 'oauth:anthropic'", + [], + ) + }) + .expect("corrupt envelope"); + + store + .overwrite_unconditional_audited( + "oauth:anthropic", + &oauth_record(), + AuditCtx::admin(AuditOp::Import), + ) + .expect("repair replace"); + + assert!( + store + .get("oauth:anthropic") + .expect("read repaired record") + .identity + .is_empty(), + "a corrupt record cannot supply identity, but it must not block token repair" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn identity_writes_reject_empty_and_control_character_account_ids() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(56); + store + .create("oauth:anthropic", &oauth_record()) + .expect("seed record"); + let empty = store + .set_identity_audited( + "oauth:anthropic", + RecordIdentity { + account_id: Some(String::new()), + email: None, + org_name: None, + }, + AuditCtx::admin(AuditOp::SetIdentity), + ) + .expect_err("empty account id must be rejected at the store sink"); + assert!( + matches!(empty, StoreOpError::Encode(ref message) if message.contains("account_id")), + "the returned error must name the invalid non-secret field: {empty}" + ); + + let control_record = oauth_record().with_identity(RecordIdentity { + account_id: Some("acct\u{0007}bad".to_string()), + email: None, + org_name: None, + }); + let control = store + .overwrite_unconditional_audited( + "oauth:anthropic", + &control_record, + AuditCtx::admin(AuditOp::Import), + ) + .expect_err("control characters must be rejected for replacement identity too"); + assert!( + matches!(control, StoreOpError::Encode(ref message) if message.contains("account_id")), + "the returned error must name the invalid non-secret field: {control}" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn set_identity_reseals_same_oauth_material_and_audits_the_metadata_change() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(29); + let existing = oauth_record(); + let payload_before = existing.payload.clone(); + let oauth_before = existing.oauth.clone(); + store.create("oauth:anthropic", &existing).expect("create"); + let handle = mint_handle().expect("mint handle"); + store + .put_handle_hash( + &handle.hash, + "oauth:anthropic", + AuditCtx::admin(AuditOp::MintHandle), + ) + .expect("store handle"); + store + .with_raw_conn(|conn| { + conn.execute( + "UPDATE credentials SET stale_pending = 1 WHERE credential_id = 'oauth:anthropic'", + [], + ) + }) + .expect("seed stale marker"); + let version_before = store.meta("oauth:anthropic").expect("meta").record_version; + + store + .set_identity_audited( + "oauth:anthropic", + RecordIdentity { + account_id: Some("acct-set".to_string()), + email: Some("set@example.com".to_string()), + org_name: Some("Set Organization".to_string()), + }, + AuditCtx::admin(AuditOp::SetIdentity), + ) + .expect("set identity"); + + let record = store.get("oauth:anthropic").expect("read"); + let meta = store.meta("oauth:anthropic").expect("meta"); + assert_eq!( + record.payload, payload_before, + "identity must not rotate payload bytes" + ); + assert_eq!( + record.oauth, oauth_before, + "identity must not replace OAuth material" + ); + assert_eq!(record.identity.account_id.as_deref(), Some("acct-set")); + assert_eq!( + meta.state, + RecordState::Active, + "identity must not alter lifecycle state" + ); + assert_eq!( + meta.record_version, + version_before + 1, + "re-sealing advances version" + ); + assert!( + meta.stale_pending, + "identity-only writes must not clear a consumer's pending refresh verdict" + ); + assert_eq!( + store.resolve_handle(&handle.raw).expect("resolve handle"), + "oauth:anthropic", + "identity-only writes must retain existing capability handles" + ); + assert_eq!( + store + .read_audit(None) + .expect("audit") + .last() + .expect("entry") + .op, + "set_identity" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn set_identity_allows_a_decryptable_needs_reauth_record() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(30); + store + .create("oauth:anthropic", &oauth_record()) + .expect("create"); + store.invalidate("oauth:anthropic").expect("invalidate"); + + store + .set_identity_audited( + "oauth:anthropic", + RecordIdentity { + account_id: Some("acct-labelled".to_string()), + email: None, + org_name: None, + }, + AuditCtx::admin(AuditOp::SetIdentity), + ) + .expect("set identity while needs reauth"); + assert_eq!( + store.meta("oauth:anthropic").expect("meta").state, + RecordState::NeedsReauth, + "identity metadata must not reactivate the credential" + ); + store + .reactivate_audited("oauth:anthropic", AuditCtx::admin(AuditOp::Reactivate)) + .expect("reactivate"); + assert_eq!( + store + .get("oauth:anthropic") + .expect("read after reactivation") + .identity + .account_id + .as_deref(), + Some("acct-labelled") + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn replacement_normalizes_a_legacy_email_only_identity() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(57); + let mut legacy = oauth_record(); + legacy.identity = RecordIdentity { + account_id: None, + email: Some("legacy@example.com".to_string()), + org_name: None, + }; + let blob = store + .seal_record("oauth:anthropic", &legacy) + .expect("seal legacy"); + store + .create("oauth:anthropic", &oauth_record()) + .expect("seed"); + store + .with_raw_conn(|conn| { + conn.execute( + "UPDATE credentials SET envelope = ?1 WHERE credential_id = 'oauth:anthropic'", + rusqlite::params![blob], + ) + }) + .expect("seed legacy envelope"); + store + .overwrite_unconditional_audited( + "oauth:anthropic", + &oauth_record(), + AuditCtx::admin(AuditOp::Import), + ) + .expect("replace"); + assert!( + store + .get("oauth:anthropic") + .expect("read") + .identity + .is_empty(), + "legacy email-only metadata must not become a served identity" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn set_identity_returns_a_typed_corrupt_error_for_an_undecodable_envelope() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(58); + store + .create("oauth:anthropic", &oauth_record()) + .expect("seed"); + let blob = envelope::seal( + &store.key, + b"not a vault record", + &RecordBinding { + credential_id: "oauth:anthropic", + record_version: 1, + }, + ) + .expect("seal malformed body"); + store + .with_raw_conn(|conn| { + conn.execute( + "UPDATE credentials SET envelope = ?1 WHERE credential_id = 'oauth:anthropic'", + rusqlite::params![blob], + ) + }) + .expect("seed malformed envelope"); + assert!(matches!( + store.set_identity_audited( + "oauth:anthropic", + RecordIdentity { + account_id: Some("acct".to_string()), + email: None, + org_name: None, + }, + AuditCtx::admin(AuditOp::SetIdentity), + ), + Err(StoreOpError::Corrupt(_)) + )); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn set_identity_returns_a_typed_decrypt_error_for_invalid_ciphertext() { + use crate::record::RecordIdentity; + + let (root, store) = tmp_store(59); + store + .create("oauth:anthropic", &oauth_record()) + .expect("seed"); + store + .with_raw_conn(|conn| { + conn.execute( + "UPDATE credentials SET envelope = X'00' WHERE credential_id = 'oauth:anthropic'", + [], + ) + }) + .expect("seed invalid ciphertext"); + assert!(matches!( + store.set_identity_audited( + "oauth:anthropic", + RecordIdentity { + account_id: Some("acct".to_string()), + email: None, + org_name: None, + }, + AuditCtx::admin(AuditOp::SetIdentity), + ), + Err(StoreOpError::Decrypt(_)) + )); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn empty_static_payload_is_rejected_before_any_write_or_audit() { let (_root, store) = tmp_store(42); diff --git a/crates/credentials-core/src/usable.rs b/crates/credentials-core/src/usable.rs index 1127354..17c5504 100644 --- a/crates/credentials-core/src/usable.rs +++ b/crates/credentials-core/src/usable.rs @@ -121,6 +121,8 @@ pub struct RecordUsability { /// deserializes with the shape intact and serves it: `VaultRecord::decode` is plain /// serde and does not pass through the sink. pub unservable_identity: bool, + /// The non-secret account id operators use to distinguish OAuth credentials. + pub account_id: Option, } /// Why a scan could not start. Distinguished from a per-record failure, which is @@ -278,6 +280,7 @@ pub fn scan(conn: &Connection, key: &MasterKey) -> Result, why: format!("{e:?}"), }, unservable_identity: false, + account_id: None, }); continue; } @@ -292,6 +295,7 @@ pub fn scan(conn: &Connection, key: &MasterKey) -> Result, why: format!("undecodable: {e}"), }, unservable_identity: false, + account_id: None, }); continue; } @@ -318,16 +322,28 @@ pub fn scan(conn: &Connection, key: &MasterKey) -> Result, }, } }; + let unservable_identity = !record.identity.is_servable(); + let (account_id, invalid_account_id) = account_id_for_output(record.identity.account_id); out.push(RecordUsability { credential_id: id, state, usability, - unservable_identity: !record.identity.is_servable(), + unservable_identity: unservable_identity || invalid_account_id, + account_id, }); } Ok(out) } +fn account_id_for_output(account_id: Option) -> (Option, bool) { + match account_id { + Some(account_id) if account_id.chars().any(char::is_control) => { + (Some("".to_string()), true) + } + account_id => (account_id, false), + } +} + #[cfg(test)] mod declared_expiry_tests { use super::*; @@ -405,6 +421,18 @@ mod declared_expiry_tests { } } +#[cfg(test)] +mod identity_output_tests { + use super::*; + + #[test] + fn account_id_controls_are_never_returned_for_cli_rendering() { + let (account_id, invalid) = account_id_for_output(Some("acct\ncontrol".to_string())); + assert_eq!(account_id.as_deref(), Some("")); + assert!(invalid); + } +} + #[cfg(test)] mod tests { use super::{is_serviceable, Usability}; diff --git a/crates/credentials-module/src/admin_surface.rs b/crates/credentials-module/src/admin_surface.rs index ab61705..084fcfa 100644 --- a/crates/credentials-module/src/admin_surface.rs +++ b/crates/credentials-module/src/admin_surface.rs @@ -327,6 +327,8 @@ fn store_err(e: StoreOpError) -> AdminOutcome { StoreOpError::CasMismatch => "version/hash mismatch (concurrent change)".to_string(), StoreOpError::AlreadyExists => "credential already exists".to_string(), StoreOpError::Fenced { .. } => "fenced out by a newer writer".to_string(), + e @ (StoreOpError::AccountIdentityMismatch { .. } + | StoreOpError::SuppliedIdentityContradictsClaim { .. }) => e.to_string(), other => format!("store error: {other}"), }; AdminOutcome::Refused(reason) @@ -357,7 +359,7 @@ mod tests { use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor}; use credentials_core::audit::{AuditCtx, AuditOp}; use credentials_core::key::{MasterKey, MASTER_KEY_LEN}; - use credentials_core::record::{CredentialKind, VaultRecord}; + use credentials_core::record::{CredentialKind, RecordIdentity, VaultRecord}; use credentials_core::store::{mint_handle, EncryptedStore}; use credentials_core::vault_id_for; @@ -437,6 +439,32 @@ mod tests { .to_string() } + fn openai_record(account_id: &str, payload: &[u8]) -> VaultRecord { + use base64::Engine as _; + + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(br#"{"alg":"none","typ":"JWT"}"#); + let claims = serde_json::json!({ + "https://api.openai.com/auth": { "chatgpt_account_id": account_id }, + }); + let claims = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes()); + let access_token = format!("{header}.{claims}.sig"); + VaultRecord::new_oauth( + "opencode", + "openai", + credentials_core::oauth::OAuthCredential { + access_token, + refresh_token: format!("refresh-{account_id}"), + expires_at_ms: Some(4_102_444_800_000), + token_url: "https://example.invalid/token".to_string(), + client_id: Some("identity-test-client".to_string()), + scopes: vec!["scope-a".to_string(), "scope-b".to_string()], + }, + payload.to_vec(), + ) + } + #[tokio::test] async fn direct_bind_full_round_trip_stores_a_credential() { let r = rig(1); @@ -455,6 +483,218 @@ mod tests { assert_eq!(last.op, "put"); } + #[tokio::test] + async fn direct_bind_set_identity_reseals_an_oauth_record_without_replacing_its_secret() { + let r = rig(11); + r.admin.record_bind(5, Principal::Direct); + let record = VaultRecord::new_oauth( + "test", + "anthropic", + credentials_core::oauth::OAuthCredential { + access_token: "opaque-access".to_string(), + refresh_token: "refresh-secret".to_string(), + expires_at_ms: Some(4_102_444_800_000), + token_url: "https://example.invalid/token".to_string(), + client_id: Some("identity-test-client".to_string()), + scopes: vec!["scope-a".to_string(), "scope-b".to_string()], + }, + b"opaque-access".to_vec(), + ); + r.store + .create("oauth:anthropic", &record) + .expect("seed OAuth record"); + let before = r.store.get("oauth:anthropic").expect("before"); + let op = AdminOpBody::SetIdentity { + v: ADMIN_OP_SCHEMA_V1, + id: "oauth:anthropic".to_string(), + identity: RecordIdentity { + account_id: Some("acct-routed".to_string()), + email: Some("routed@example.com".to_string()), + org_name: None, + }, + }; + let body = String::from_utf8(op.to_bytes().expect("encode op")).expect("UTF-8 JSON"); + let (tag, _) = challenge_and_sign(&r, 5, &body); + + let outcome = r.admin.execute(5, body.as_bytes(), &tag).await; + assert!( + matches!(outcome, AdminOutcome::Ok(_)), + "set identity must route" + ); + let after = r.store.get("oauth:anthropic").expect("after"); + assert_eq!( + after.payload, before.payload, + "route op must preserve payload bytes" + ); + assert_eq!( + after.oauth, before.oauth, + "route op must preserve OAuth material" + ); + assert_eq!(after.identity.account_id.as_deref(), Some("acct-routed")); + assert_eq!( + r.store + .read_audit(None) + .expect("audit") + .last() + .expect("entry") + .op, + "set_identity" + ); + } + + #[tokio::test] + async fn direct_bind_set_identity_refuses_an_empty_account_id_at_the_store_sink() { + let r = rig(12); + r.admin.record_bind(5, Principal::Direct); + r.store + .create( + "oauth:anthropic", + &VaultRecord::new_oauth( + "test", + "anthropic", + credentials_core::oauth::OAuthCredential { + access_token: "opaque-access".to_string(), + refresh_token: "refresh-secret".to_string(), + expires_at_ms: Some(4_102_444_800_000), + token_url: "https://example.invalid/token".to_string(), + client_id: Some("identity-test-client".to_string()), + scopes: vec!["scope-a".to_string(), "scope-b".to_string()], + }, + b"opaque-access".to_vec(), + ), + ) + .expect("seed OAuth record"); + let op = AdminOpBody::SetIdentity { + v: ADMIN_OP_SCHEMA_V1, + id: "oauth:anthropic".to_string(), + identity: RecordIdentity { + account_id: Some(String::new()), + email: None, + org_name: None, + }, + }; + let body = String::from_utf8(op.to_bytes().expect("encode op")).expect("UTF-8 JSON"); + let (tag, _) = challenge_and_sign(&r, 5, &body); + + let outcome = r.admin.execute(5, body.as_bytes(), &tag).await; + assert!( + matches!(outcome, AdminOutcome::Refused(ref message) if message.contains("account_id")), + "a MAC-authenticated op must still be refused for invalid identity" + ); + assert!( + r.store + .get("oauth:anthropic") + .expect("record remains readable") + .identity + .is_empty(), + "the rejected operation must not persist any metadata" + ); + } + + #[tokio::test] + async fn direct_bind_store_refuses_an_invalid_import_identity() { + let r = rig(13); + r.admin.record_bind(5, Principal::Direct); + let record = VaultRecord::new_oauth( + "import", + "anthropic", + credentials_core::oauth::OAuthCredential { + access_token: "opaque-access".to_string(), + refresh_token: "refresh-secret".to_string(), + expires_at_ms: Some(4_102_444_800_000), + token_url: "https://example.invalid/token".to_string(), + client_id: Some("identity-test-client".to_string()), + scopes: vec!["scope-a".to_string(), "scope-b".to_string()], + }, + b"opaque-access".to_vec(), + ) + .with_identity(RecordIdentity { + account_id: Some("acct-good".to_string()), + email: Some("invalid\u{0007}email@example.com".to_string()), + org_name: None, + }); + let op = AdminOpBody::Store { + v: ADMIN_OP_SCHEMA_V1, + id: "oauth:anthropic".to_string(), + record: Box::new(record), + audit_op: credentials_core::admin_ops::AdminAuditOp::Import, + mode: credentials_core::admin_ops::StoreMode::Create, + }; + let body = String::from_utf8(op.to_bytes().expect("encode op")).expect("UTF-8 JSON"); + let (tag, _) = challenge_and_sign(&r, 5, &body); + + let outcome = r.admin.execute(5, body.as_bytes(), &tag).await; + assert!( + matches!(outcome, AdminOutcome::Refused(ref message) if message.contains("email")), + "authenticated imports must enforce the same store identity boundary" + ); + assert!(r.store.get("oauth:anthropic").is_err()); + } + + #[tokio::test] + async fn connected_identity_mismatch_refusal_renders_both_accounts_and_remedies() { + let r = rig(14); + r.admin.record_bind(5, Principal::Direct); + let existing = openai_record("acct-retained", b"old-token").with_identity(RecordIdentity { + account_id: Some("acct-retained".to_string()), + email: Some("retained@example.com".to_string()), + org_name: None, + }); + r.store + .create("oauth:openai", &existing) + .expect("seed OAuth record"); + let op = AdminOpBody::StoreWithIdentityPolicy { + v: ADMIN_OP_SCHEMA_V1, + id: "oauth:openai".to_string(), + record: Box::new(openai_record("acct-incoming", b"new-token")), + audit_op: credentials_core::admin_ops::AdminAuditOp::Import, + clear_identity: false, + }; + let body = String::from_utf8(op.to_bytes().expect("encode op")).expect("UTF-8 JSON"); + let (tag, _) = challenge_and_sign(&r, 5, &body); + + let AdminOutcome::Refused(message) = r.admin.execute(5, body.as_bytes(), &tag).await else { + panic!("account mismatch must surface as admin_refused"); + }; + assert_eq!( + message, + "incoming material names account 'acct-incoming', but identity preservation would retain account 'acct-retained'; pass `--account-id ` with 'acct-incoming' or `--clear-identity`; or afterwards: ck auth set-identity oauth:openai --account-id acct-incoming" + ); + } + + #[tokio::test] + async fn connected_create_identity_claim_refusal_renders_as_admin_refused() { + let r = rig(15); + r.admin.record_bind(5, Principal::Direct); + let op = AdminOpBody::Store { + v: ADMIN_OP_SCHEMA_V1, + id: "oauth:openai".to_string(), + record: Box::new(openai_record("acct-derived", b"new-token").with_identity( + RecordIdentity { + account_id: Some("acct-supplied".to_string()), + email: Some("supplied@example.com".to_string()), + org_name: None, + }, + )), + audit_op: credentials_core::admin_ops::AdminAuditOp::Import, + mode: credentials_core::admin_ops::StoreMode::Create, + }; + let body = String::from_utf8(op.to_bytes().expect("encode op")).expect("UTF-8 JSON"); + let (tag, _) = challenge_and_sign(&r, 5, &body); + + let AdminOutcome::Refused(message) = r.admin.execute(5, body.as_bytes(), &tag).await else { + panic!("a contradictory create identity must surface as admin_refused"); + }; + assert_eq!( + message, + "supplied identity names account 'acct-supplied', but incoming material names account 'acct-derived'; drop `--account-id`, or fix the export; the token's own claim is authoritative" + ); + assert!(matches!( + r.store.get("oauth:openai"), + Err(credentials_core::store::StoreOpError::NotFound) + )); + } + #[tokio::test] async fn non_direct_principals_never_reach_admin() { let r = rig(2); diff --git a/crates/credentials-module/src/bin/credentials_cli.rs b/crates/credentials-module/src/bin/credentials_cli.rs index 2ecdd39..6497c08 100644 --- a/crates/credentials-module/src/bin/credentials_cli.rs +++ b/crates/credentials-module/src/bin/credentials_cli.rs @@ -24,6 +24,7 @@ //! `--payload-file` bytes exactly, and do not accept `--expires-ms`. //! mint-signing-key --id signing:[:] [--replace] //! import --source opencode|pi|antigravity --id --json +//! set-identity --account-id [--email ] [--org-name ] | --clear //! invalidate --id //! rotate-master-key //! mint-handle --id print a fresh handle (once) @@ -57,7 +58,7 @@ use credentials_core::admin_ops::{AdminAuditOp, AdminOpBody, StoreMode, ADMIN_OP use credentials_core::contract::{MODULE_ID, STORAGE_NAMESPACE}; use credentials_core::credential_id::{default_refresh_adapter, parse_credential_id, AuthMethod}; use credentials_core::key::MasterKey; -use credentials_core::record::{CredentialKind, VaultRecord}; +use credentials_core::record::{CredentialKind, RecordIdentity, VaultRecord}; use credentials_core::resolver::{self, KeySource, MasterKeyError, ResolverConfig}; use credentials_core::store::{EncryptedStore, GrantOperation, StoreOpError}; use ring::rand::SystemRandom; @@ -247,6 +248,7 @@ fn run() -> Result<(), CliError> { "put" => cmd_put(&global, &args), "mint-signing-key" => cmd_mint_signing_key(&global, &args), "import" => cmd_import(&global, &args), + "set-identity" => cmd_set_identity(&global, &args), "login" => cmd_login(&global, &args), "invalidate" => cmd_invalidate(&global, &args), "reactivate" => cmd_reactivate(&global, &args), @@ -321,7 +323,17 @@ fn reject_unknown_args(command: &str, args: &[String]) -> Result<(), CliError> { "--client-id", ], "mint-signing-key" => &["--id"], - "import" => &["--source", "--provider", "--id", "--json", "--adapter"], + "import" => &[ + "--source", + "--provider", + "--id", + "--json", + "--adapter", + "--account-id", + "--email", + "--org-name", + ], + "set-identity" => &["--account-id", "--email", "--org-name"], "login" => &["--provider", "--id", "--payload-file", "--account"], "invalidate" | "reactivate" | "mint-handle" | "revoke-all-handles" | "remove" => &["--id"], "logout" => &["--provider", "--id"], @@ -337,11 +349,17 @@ fn reject_unknown_args(command: &str, args: &[String]) -> Result<(), CliError> { let bool_flags: &[&str] = match command { "put" => &["--replace"], "mint-signing-key" => &["--replace"], - "import" => &["--replace"], + "import" => &["--replace", "--clear-identity"], + "set-identity" => &["--clear"], "login" => &["--replace", "--no-listener", "--device"], _ => &[], }; - let mut i = 0; + let mut i = + if command == "set-identity" && args.first().is_some_and(|arg| !arg.starts_with("--")) { + 1 + } else { + 0 + }; while i < args.len() { let arg = &args[i]; if bool_flags.contains(&arg.as_str()) { @@ -380,6 +398,7 @@ fn usage_short() -> String { put ingest an api key, session cookie, or opaque secret\n\ mint-signing-key generate and custody a new Ed25519 signing key\n\ import import from opencode/pi/gemini-cli/antigravity\n\ + set-identity attach non-secret account metadata to one credential\n\ mint-handle mint a capability handle for a credential\n\ revoke-handle revoke one capability handle\n\ revoke-all-handles revoke every handle for a credential\n\ @@ -495,14 +514,28 @@ fn help_verb(verb: &str) -> String { "ck auth import --source --id \ --json \n\ \x20 [--provider ] [--adapter ] [--replace]\n\ + \x20 [--account-id [--email ] [--org-name ] | --clear-identity]\n\ \n\ opencode/pi read auth.json (--provider selects one entry; an apikey:

id\n\ imports a {type:api,key} entry as a static key, an oauth id imports tokens);\n\ gemini-cli reads ~/.gemini/oauth_creds.json (single credential, no --provider);\n\ antigravity reads ~/.config/opencode/antigravity-accounts.json (accounts array;\n\ --provider selects an account by email/index, default activeIndex);\n\ - --adapter overrides the method-derived refresh adapter;\n\ - --replace overwrites an existing id (fix a wrong-source import; keeps handles)." + --adapter overrides the method-derived refresh adapter;\n\ + --account-id attaches non-secret account metadata (required with --email or\n\ + --org-name); --clear-identity removes it;\n\ + --replace overwrites an existing id (fix a wrong-source import; keeps handles)\n\ + and preserves prior identity only when the incoming token belongs to the same\n\ + account; a detectable mismatch requires explicit identity flags to override or clear it." + } + "set-identity" => { + "ck auth set-identity --account-id [--email ] \ + [--org-name ] | --clear\n\ + \n\ + Update only non-secret account metadata. The vault decrypts and re-seals the\n\ + existing record without replacing token material, keeps its lifecycle state,\n\ + and bumps record_version because the encrypted envelope changed. Works for any\n\ + decryptable record, including needs-reauth or retired records." } "mint-handle" => { "ck auth mint-handle --id \n\ @@ -1012,14 +1045,24 @@ fn cmd_put(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { Ok(()) } -/// Build an `admin.store` op body. +/// Build an admin store op, upgrading unconditional replacement to the identity-policy +/// discriminator so an older daemon refuses semantics it cannot preserve. fn store_op(id: &str, record: VaultRecord, audit_op: AdminAuditOp, mode: StoreMode) -> AdminOpBody { - AdminOpBody::Store { - v: ADMIN_OP_SCHEMA_V1, - id: id.to_string(), - record: Box::new(record), - audit_op, - mode, + match mode { + StoreMode::ReplaceUnconditional => AdminOpBody::StoreWithIdentityPolicy { + v: ADMIN_OP_SCHEMA_V1, + id: id.to_string(), + record: Box::new(record), + audit_op, + clear_identity: false, + }, + mode => AdminOpBody::Store { + v: ADMIN_OP_SCHEMA_V1, + id: id.to_string(), + record: Box::new(record), + audit_op, + mode, + }, } } @@ -1032,10 +1075,66 @@ fn hex_lower(bytes: &[u8]) -> String { s } +#[derive(Default)] +struct IdentityFlags { + clear: bool, + account_id: Option, + email: Option, + org_name: Option, +} + +fn identity_flags( + args: &[String], + clear_flag: &str, + require_account_id: bool, +) -> Result { + let flags = IdentityFlags { + clear: has_flag(args, clear_flag), + account_id: optional(args, "--account-id"), + email: optional(args, "--email"), + org_name: optional(args, "--org-name"), + }; + let has_identity_fields = + flags.account_id.is_some() || flags.email.is_some() || flags.org_name.is_some(); + if flags.clear && has_identity_fields { + return Err(CliError::Usage(format!( + "{clear_flag} is mutually exclusive with --account-id, --email, and --org-name" + ))); + } + if flags.clear { + return Ok(flags); + } + let Some(account_id) = flags.account_id.as_deref() else { + if has_identity_fields || require_account_id { + return Err(CliError::Usage( + "--account-id is required when setting identity metadata".to_string(), + )); + } + return Ok(flags); + }; + if account_id.trim().is_empty() { + return Err(CliError::Usage( + "--account-id must not be empty".to_string(), + )); + } + if account_id.chars().any(char::is_control) { + return Err(CliError::Usage( + "--account-id must not contain control characters".to_string(), + )); + } + if account_id.len() > 256 { + return Err(CliError::Usage( + "--account-id must be at most 256 bytes".to_string(), + )); + } + Ok(flags) +} + fn cmd_import(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { let source = required(args, "--source")?; let id = required(args, "--id")?; let json_path = required(args, "--json")?; + let requested_identity = identity_flags(args, "--clear-identity", false)?; let raw = std::fs::read(&json_path).map_err(|e| CliError::Io(format!("reading {json_path}: {e}")))?; let provider_sel = optional(args, "--provider"); @@ -1050,7 +1149,8 @@ fn cmd_import(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { "signing keys must be generated with mint-signing-key, not imported".to_string(), )); } - let record = if matches!(parsed.method, Some(AuthMethod::ApiKey)) { + let mut imported_email = None; + let mut record = if matches!(parsed.method, Some(AuthMethod::ApiKey)) { // API key → a static record (no adapter, no refresh). `--provider` selects the // entry from a multi-provider auth.json; default to the parsed provider. let provider = provider_sel @@ -1065,14 +1165,13 @@ fn cmd_import(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { // Antigravity carries an identity the other import sources do not: its access // tokens are opaque, so the email in the plugin store is the only thing that // can distinguish two accounts downstream. - let mut identity_email: Option = None; let oauth = if source == "antigravity" { // For antigravity the credentials live in the plugin's accounts-array // store instead of the normal provider auth.json file — read the selected // account and pack its refresh. credentials_core::oauth::import_antigravity_account(&raw, provider_sel.as_deref()).map( |imported| { - identity_email = imported.email; + imported_email = imported.email; imported.oauth }, ) @@ -1093,32 +1192,27 @@ fn cmd_import(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { )) })?; let payload = oauth.access_token.clone().into_bytes(); - let record = VaultRecord::new_oauth(source, adapter, oauth, payload); - match identity_email { - // Only attach an identity when one was actually read. An unconditional - // `with_identity` would stamp an all-None identity onto every other import - // source, which reads as "captured, and empty" rather than "never captured". - // - // THE EMAIL GOES IN BOTH FIELDS, and `account_id` is the load-bearing one. - // The read surface serves `account_id` as the identity consumers join on, - // and treats `email` as display metadata; a record carrying only `email` - // renders a value while still resolving no identity, so a consumer - // labelling per account keeps collapsing and the wire looks unchanged. - // The read surface already states this as an invariant -- email never - // ships without account_id -- and populating one field alone breaks it. - // - // An email is a legitimate account_id here: consumers treat it as an opaque - // stable string, and antigravity has no other per-account identifier, - // since its access tokens are opaque rather than JWTs. - Some(email) => record.with_identity(credentials_core::record::RecordIdentity { - account_id: Some(email.clone()), - email: Some(email), - org_name: None, - }), - None => record, - } + VaultRecord::new_oauth(source, adapter, oauth, payload) }; + if !requested_identity.clear { + record = match requested_identity.account_id { + Some(account_id) => record.with_identity(RecordIdentity { + account_id: Some(account_id), + email: requested_identity.email.or(imported_email), + org_name: requested_identity.org_name, + }), + None => match imported_email { + Some(email) => record.with_identity(RecordIdentity { + account_id: Some(email.clone()), + email: Some(email), + org_name: None, + }), + None => record, + }, + }; + } + // `--replace` overwrites an existing credential UNCONDITIONALLY (re-seal at // version+1, reset to active, keep the handle), for fixing a credential imported // from the wrong source. Without it, import is CREATE-ONLY (an existing id is an @@ -1126,12 +1220,13 @@ fn cmd_import(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { if has_flag(args, "--replace") { commit_admin( global, - store_op( - &id, - record, - AdminAuditOp::Import, - StoreMode::ReplaceUnconditional, - ), + AdminOpBody::StoreWithIdentityPolicy { + v: ADMIN_OP_SCHEMA_V1, + id: id.clone(), + record: Box::new(record), + audit_op: AdminAuditOp::Import, + clear_identity: requested_identity.clear, + }, )?; println!("replaced {id}"); } else { @@ -1144,6 +1239,36 @@ fn cmd_import(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { Ok(()) } +fn cmd_set_identity(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { + let id = args + .first() + .filter(|arg| !arg.starts_with("--")) + .cloned() + .ok_or_else(|| { + CliError::Usage("set-identity requires a positional ".to_string()) + })?; + let requested = identity_flags(args, "--clear", true)?; + let identity = if requested.clear { + RecordIdentity::default() + } else { + RecordIdentity { + account_id: requested.account_id, + email: requested.email, + org_name: requested.org_name, + } + }; + commit_admin( + global, + AdminOpBody::SetIdentity { + v: ADMIN_OP_SCHEMA_V1, + id: id.clone(), + identity, + }, + )?; + println!("updated identity for {id}"); + Ok(()) +} + /// Vault-native first-party OAuth login: drive an interactive authorization-code + /// PKCE flow so the vault mints and SOLELY custodies an INDEPENDENT refresh token, /// eliminating the dual-custody rotation race by construction. The operator opens a @@ -3204,8 +3329,9 @@ fn cmd_usable(global: &GlobalArgs) -> Result<(), CliError> { } Usability::Stranded => { println!( - " {id:34} oauth {} STRANDED: no access token and no refresh token", - row.state + " {id:34} oauth {} account={} STRANDED: no access token and no refresh token", + row.state, + row.account_id.as_deref().unwrap_or("none") ); stranded += 1; } @@ -3286,7 +3412,11 @@ fn cmd_usable(global: &GlobalArgs) -> Result<(), CliError> { } None => "no expiry recorded".to_string(), }; - println!(" {id:34} oauth {} {ttl}", row.state); + println!( + " {id:34} oauth {} account={} {ttl}", + row.state, + row.account_id.as_deref().unwrap_or("none") + ); serviceable += 1; } } diff --git a/crates/credentials-module/src/main.rs b/crates/credentials-module/src/main.rs index feffe25..01c5b64 100644 --- a/crates/credentials-module/src/main.rs +++ b/crates/credentials-module/src/main.rs @@ -6283,6 +6283,62 @@ mod tests { assert_eq!(legacy_result.account_id, None); } + #[tokio::test] + async fn get_serves_identity_attached_after_an_oauth_record_was_created() { + use credentials_core::oauth::OAuthCredential; + use credentials_core::record::RecordIdentity; + + let (surface, store, _db) = tmp_surface_with_store(31); + let oauth = OAuthCredential { + access_token: "opaque-access".to_string(), + refresh_token: "refresh-secret".to_string(), + expires_at_ms: Some(4_102_444_800_000), + token_url: "https://example.invalid/token".to_string(), + client_id: None, + scopes: Vec::new(), + }; + store + .create( + "oauth:anthropic:late-labelled", + &VaultRecord::new_oauth("import", "anthropic", oauth, b"opaque-access".to_vec()), + ) + .expect("create OAuth record"); + let handle = credentials_core::store::mint_handle().expect("mint"); + store + .put_handle_hash( + &handle.hash, + "oauth:anthropic:late-labelled", + AuditCtx::admin(AuditOp::MintHandle), + ) + .expect("store handle"); + store + .set_identity_audited( + "oauth:anthropic:late-labelled", + RecordIdentity { + account_id: Some("acct-late".to_string()), + email: None, + org_name: None, + }, + AuditCtx::admin(AuditOp::SetIdentity), + ) + .expect("set identity"); + + let read_surface::GetOutcome::Ok(result) = surface + .get( + 1, + &read_surface::GetParams { + handle: handle.raw, + min_ttl_ms: None, + force_refresh: false, + }, + ) + .await + else { + panic!("expected a served OAuth record"); + }; + assert_eq!(result.account_id.as_deref(), Some("acct-late")); + } + /// `wrap_result` is the single seam that produces the route reply envelope /// `{"result": ...}`. Every route op must go through it so a future envelope change /// moves every operation at once instead of silently leaving some ops on the old diff --git a/crates/credentials-module/tests/cli_admin.rs b/crates/credentials-module/tests/cli_admin.rs index cbf49c2..62742fd 100644 --- a/crates/credentials-module/tests/cli_admin.rs +++ b/crates/credentials-module/tests/cli_admin.rs @@ -949,6 +949,429 @@ fn an_antigravity_import_stores_a_resolvable_account_identity() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn antigravity_import_prefers_explicit_account_id_and_only_overrides_source_email_when_requested() { + use credentials_core::resolver::{KeySource, ResolverConfig}; + + let root = tmp_root("ag-import-explicit-identity"); + let data_dir = root.join("vault"); + let key_path = root.join("keys").join("master.key"); + std::fs::create_dir_all(&data_dir).expect("vault dir"); + std::fs::create_dir_all(key_path.parent().expect("key dir")).expect("key dir"); + let source = root.join("antigravity-accounts.json"); + std::fs::write( + &source, + br#"{"version":4,"activeIndex":0,"accounts":[{"email":"source@example.com","refreshToken":"1//0-source","projectId":"project"}]}"#, + ) + .expect("write source"); + let run = |args: &[&str]| -> std::process::Output { + cli() + .args(args) + .arg("--data-dir") + .arg(&data_dir) + .arg("--key-path") + .arg(&key_path) + .output() + .expect("run ck-auth") + }; + let open_store = || { + let key = credentials_core::resolver::resolve( + &ResolverConfig { + data_dir: data_dir.clone(), + source: KeySource::OperatorPath { + path: key_path.clone(), + }, + }, + None, + ) + .expect("resolve key"); + let sqlite = open_sqlite(&StorageDescriptor { + module_id: credentials_core::contract::MODULE_ID.into(), + storage_namespace: credentials_core::contract::STORAGE_NAMESPACE.into(), + isolation: Isolation::Module, + backend: StorageBackend::Sqlite { + path: data_dir.join("store.db").to_string_lossy().into_owned(), + }, + }) + .expect("open store"); + EncryptedStore::migrate(&sqlite).expect("migrate"); + EncryptedStore::open(sqlite, key).expect("open vault") + }; + + assert!(run(&["bootstrap"]).status.success(), "bootstrap"); + assert!( + run(&[ + "import", + "--source", + "antigravity", + "--id", + "antigravity:google:source-email", + "--json", + source.to_str().expect("source path"), + "--account-id", + "acct-explicit", + ]) + .status + .success(), + "explicit account import" + ); + let store = open_store(); + let source_email = store + .get("antigravity:google:source-email") + .expect("record"); + assert_eq!( + source_email.identity.account_id.as_deref(), + Some("acct-explicit") + ); + assert_eq!( + source_email.identity.email.as_deref(), + Some("source@example.com"), + "the source email remains useful display metadata unless an operator overrides it" + ); + drop(store); + + assert!( + run(&[ + "import", + "--source", + "antigravity", + "--id", + "antigravity:google:operator-email", + "--json", + source.to_str().expect("source path"), + "--account-id", + "acct-operator", + "--email", + "operator@example.com", + ]) + .status + .success(), + "operator email import" + ); + let operator_email = open_store() + .get("antigravity:google:operator-email") + .expect("record"); + assert_eq!( + operator_email.identity.account_id.as_deref(), + Some("acct-operator") + ); + assert_eq!( + operator_email.identity.email.as_deref(), + Some("operator@example.com") + ); + + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn import_and_set_identity_attach_sticky_account_metadata_without_replacing_secret_material() { + use credentials_core::resolver::{KeySource, ResolverConfig}; + + let root = tmp_root("import-identity"); + let data_dir = root.join("vault"); + let key_path = root.join("keys").join("master.key"); + std::fs::create_dir_all(&data_dir).expect("vault dir"); + std::fs::create_dir_all(key_path.parent().expect("key dir")).expect("key dir"); + let source = root.join("auth.json"); + std::fs::write( + &source, + r#"{"refresh":"refresh-original","access":"opaque-original","expires":4102444800000}"#, + ) + .expect("write source"); + + let run = |args: &[&str]| -> std::process::Output { + cli() + .args(args) + .arg("--data-dir") + .arg(&data_dir) + .arg("--key-path") + .arg(&key_path) + .output() + .expect("run ck-auth") + }; + let open_record = || { + let config = ResolverConfig { + data_dir: data_dir.clone(), + source: KeySource::OperatorPath { + path: key_path.clone(), + }, + }; + let key = credentials_core::resolver::resolve(&config, None).expect("resolve key"); + let sqlite = open_sqlite(&StorageDescriptor { + module_id: credentials_core::contract::MODULE_ID.into(), + storage_namespace: credentials_core::contract::STORAGE_NAMESPACE.into(), + isolation: Isolation::Module, + backend: StorageBackend::Sqlite { + path: data_dir.join("store.db").to_string_lossy().into_owned(), + }, + }) + .expect("open store"); + EncryptedStore::migrate(&sqlite).expect("migrate"); + EncryptedStore::open(sqlite, key).expect("open vault") + }; + + assert!(run(&["bootstrap"]).status.success(), "bootstrap"); + let imported = run(&[ + "import", + "--source", + "opencode", + "--id", + "oauth:anthropic", + "--json", + source.to_str().expect("source path"), + "--account-id", + "acct-import", + "--email", + "import@example.com", + "--org-name", + "Import Organization", + ]); + assert!( + imported.status.success(), + "import: {}", + String::from_utf8_lossy(&imported.stderr) + ); + let store = open_record(); + let imported_record = store.get("oauth:anthropic").expect("imported record"); + assert_eq!( + imported_record.identity.account_id.as_deref(), + Some("acct-import"), + "the import flag must land in the identity field consumers resolve" + ); + drop(store); + + let mut legacy = imported_record; + legacy.identity.account_id = Some("acct\ncontrol".to_string()); + let key = credentials_core::resolver::resolve( + &ResolverConfig { + data_dir: data_dir.clone(), + source: KeySource::OperatorPath { + path: key_path.clone(), + }, + }, + None, + ) + .expect("resolve key for legacy envelope"); + let envelope = credentials_core::envelope::seal( + &key, + &legacy.encode().expect("encode legacy record"), + &credentials_core::envelope::RecordBinding { + credential_id: "oauth:anthropic", + record_version: 1, + }, + ) + .expect("seal legacy record"); + let conn = rusqlite::Connection::open(data_dir.join("store.db")).expect("open raw store"); + conn.execute( + "UPDATE credentials SET envelope = ?1 WHERE credential_id = 'oauth:anthropic'", + rusqlite::params![envelope], + ) + .expect("seed legacy identity"); + let usable_legacy = run(&["usable"]); + let usable_legacy_stdout = String::from_utf8_lossy(&usable_legacy.stdout); + assert!(usable_legacy.status.success()); + assert!(usable_legacy_stdout.contains("account=")); + assert!(!usable_legacy_stdout.contains("acct\ncontrol")); + assert!(usable_legacy_stdout.contains("unservable identity: 1")); + + let store = open_record(); + let material_fixture = credentials_core::record::VaultRecord::new_oauth( + "fixture", + "anthropic", + credentials_core::oauth::OAuthCredential { + access_token: "opaque-fixture-access".to_string(), + refresh_token: "fixture-refresh-secret".to_string(), + expires_at_ms: Some(4_102_444_800_000), + token_url: "https://fixture.invalid/token".to_string(), + client_id: Some("fixture-client".to_string()), + scopes: vec!["scope-a".to_string(), "scope-b".to_string()], + }, + b"opaque-fixture-access".to_vec(), + ); + store + .overwrite_unconditional_audited( + "oauth:anthropic", + &material_fixture, + credentials_core::audit::AuditCtx::admin(credentials_core::audit::AuditOp::Import), + ) + .expect("replace with field-complete OAuth fixture"); + let before_set = store.get("oauth:anthropic").expect("before set"); + drop(store); + let set = run(&[ + "set-identity", + "oauth:anthropic", + "--account-id", + "acct-set", + "--email", + "set@example.com", + ]); + assert!( + set.status.success(), + "set-identity: {}", + String::from_utf8_lossy(&set.stderr) + ); + let store = open_record(); + let after_set = store.get("oauth:anthropic").expect("after set"); + assert_eq!( + after_set.payload, before_set.payload, + "set-identity must not rotate payload" + ); + assert_eq!( + after_set.oauth, before_set.oauth, + "set-identity must not replace OAuth material" + ); + assert_eq!(after_set.identity.account_id.as_deref(), Some("acct-set")); + assert!( + store + .read_audit(None) + .expect("audit") + .iter() + .any(|entry| entry.op == "set_identity"), + "identity-only writes must leave an audit entry" + ); + drop(store); + + let usable = run(&["usable"]); + let usable_stdout = String::from_utf8_lossy(&usable.stdout); + assert!( + usable.status.success(), + "usable: {}", + String::from_utf8_lossy(&usable.stderr) + ); + assert!( + usable_stdout + .lines() + .any(|line| line.contains("oauth:anthropic") && line.contains("account=acct-set")), + "usable must show non-secret account identity presence: {usable_stdout}" + ); + + std::fs::write( + &source, + r#"{"refresh":"refresh-rotated","access":"opaque-rotated","expires":4102444800000}"#, + ) + .expect("rotate source"); + let replacement = run(&[ + "import", + "--source", + "opencode", + "--id", + "oauth:anthropic", + "--json", + source.to_str().expect("source path"), + "--replace", + ]); + assert!( + replacement.status.success(), + "replace: {}", + String::from_utf8_lossy(&replacement.stderr) + ); + let sticky = open_record() + .get("oauth:anthropic") + .expect("sticky identity"); + assert_eq!(sticky.identity.account_id.as_deref(), Some("acct-set")); + assert_eq!( + sticky.oauth.as_ref().expect("OAuth").refresh_token, + "refresh-rotated" + ); + assert_eq!( + sticky.oauth.as_ref().expect("OAuth").access_token, + "opaque-rotated" + ); + + let cleared = run(&[ + "import", + "--source", + "opencode", + "--id", + "oauth:anthropic", + "--json", + source.to_str().expect("source path"), + "--replace", + "--clear-identity", + ]); + assert!( + cleared.status.success(), + "clear identity: {}", + String::from_utf8_lossy(&cleared.stderr) + ); + let cleared_record = open_record() + .get("oauth:anthropic") + .expect("cleared identity"); + assert!( + cleared_record.identity.is_empty(), + "--clear-identity must override sticky preservation" + ); + assert_eq!( + cleared_record.oauth.as_ref().expect("OAuth").refresh_token, + "refresh-rotated" + ); + assert_eq!( + cleared_record.oauth.as_ref().expect("OAuth").access_token, + "opaque-rotated" + ); + + assert!( + run(&[ + "set-identity", + "oauth:anthropic", + "--account-id", + "acct-to-clear", + ]) + .status + .success(), + "set identity before clear" + ); + assert!( + run(&["set-identity", "oauth:anthropic", "--clear"]) + .status + .success(), + "set-identity --clear" + ); + assert!( + open_record() + .get("oauth:anthropic") + .expect("cleared by set-identity") + .identity + .is_empty(), + "set-identity --clear must drop metadata without a source re-import" + ); + + let email_only = run(&[ + "import", + "--source", + "opencode", + "--id", + "oauth:other", + "--json", + source.to_str().expect("source path"), + "--email", + "missing-account@example.com", + ]); + assert!( + !email_only.status.success(), + "email without account_id must refuse" + ); + assert!( + String::from_utf8_lossy(&email_only.stderr).contains("--account-id is required"), + "email-only refusal must name the missing field" + ); + + for invalid_value in ["", "account\ncontrol", &"x".repeat(257)] { + let invalid_output = run(&[ + "set-identity", + "oauth:anthropic", + "--account-id", + invalid_value, + ]); + assert!( + !invalid_output.status.success(), + "invalid account id {invalid_value:?} must refuse" + ); + } + + let _ = std::fs::remove_dir_all(&root); +} + /// `events` separates three outcomes an operator must not confuse. /// /// "no events" and "this store cannot record events" would otherwise render the same, diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index e243c80..0c0adf7 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -220,7 +220,17 @@ Source-specific notes: - `--replace` overwrites an existing id unconditionally (re-seal at version+1, reset to active), for fixing a credential imported from the wrong source. Existing handles keep resolving to the id — **no re-mint needed**. Without `--replace`, - `import` is create-only and an existing id is refused. + `import` is create-only and an existing id is refused. `--account-id ` attaches + non-secret account metadata; `--email` and `--org-name` require it, while + `--clear-identity` is mutually exclusive with all three. A token-only + `import --replace` preserves the existing identity; explicit identity flags override + it and `--clear-identity` drops it. Preservation assumes the replacement token belongs + to the same account; after a re-login into a different account, run `ck auth set-identity --account-id `. + To label a vault-custodied credential without + replacing its token family from a source file, use `ck auth set-identity + --account-id [--email ] [--org-name ]` (or + `--clear`): it re-seals unchanged material, keeps lifecycle state, and bumps + `record_version`. **Put a static credential** (API key / DSN / opaque). Use `--payload-file ` for a secret so it never appears in the process list or shell history; `--payload @@ -607,6 +617,7 @@ These values come from the closed `AuditOp` enum and name the mutation or chain | `import` | Import a credential from an external source format. | | `login` | Mint a vault-native first-party OAuth credential. | | `overwrite` | Replace a credential under an unconditional or compare-and-set write path. | +| `set_identity` | Re-seal unchanged credential material with updated non-secret account identity. | | `invalidate` | Mark a credential as needing re-authentication. | | `rotate_master_key` | Re-wrap the vault under a new master key. | | `refresh_commit` | Commit new tokens from a vault-owned refresh. | diff --git a/scripts/gate.sh b/scripts/gate.sh index 5991cc6..e43fa39 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -227,13 +227,11 @@ stream and pass the arm without ever seeing it skip." # follows it), and any gap between the floor and the real count is how many can go # before anyone is told. Measured 402 across the workspace's suites at the time of # writing; an earlier floor of 200 left a third of them free to disappear. -# The current measured total is 470 after adding redacted-Debug pins for VaultRecord and -# AdminOpBody, on top of the request-shape pins for the read surface and the source-level -# checks that keep diagnostic enum documentation complete. +# The current measured total is 501 after adding the derived-claim validation test. # # Raise this when tests are added. A failure here is normally that, not a defect -- # but it should be a deliberate edit rather than a number nobody revisits. - run_expect 470 "workspace unit + integration" \ + run_expect 501 "workspace unit + integration" \ cargo test --locked --workspace # Two independent defences, because each catches what the other misses: