feat: replace private cas envelope

This commit is contained in:
Eric Wendland 2026-07-05 23:02:59 +02:00
commit 63ea36bb51
10 changed files with 193 additions and 89 deletions

3
Cargo.lock generated
View file

@ -751,6 +751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@ -1524,10 +1525,12 @@ dependencies = [
name = "geth-cas" name = "geth-cas"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm",
"blake3", "blake3",
"geth-codec", "geth-codec",
"geth-types", "geth-types",
"hex", "hex",
"rand 0.9.4",
"serde", "serde",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",

View file

@ -35,6 +35,7 @@ repository = "https://example.invalid/local/geth"
rust-version = "1.91" rust-version = "1.91"
[workspace.dependencies] [workspace.dependencies]
aes-gcm = "0.10"
anyhow = "1" anyhow = "1"
async-trait = "0.1" async-trait = "0.1"
automerge = "0.7" automerge = "0.7"

View file

@ -176,8 +176,9 @@ The bootstrap implementation provides:
`--bearer-secret <secret>` `--bearer-secret <secret>`
- private CAS envelope commands: - private CAS envelope commands:
`geth cas add-private <resource> <path>` and `geth cas add-private <resource> <path>` and
`geth cas get-private <resource> <hash> --out <path>`. These use local `geth cas get-private <resource> <hash> --out <path>`. New writes use an
resource secret epochs and are a prototype envelope, not audited AEAD. AES-256-GCM envelope bound to the resource and local secret epoch. This does
not claim forward secrecy or post-compromise security.
- local CAS tree objects describe file trees and are stored as CAS blobs - local CAS tree objects describe file trees and are stored as CAS blobs
- local file-root commands: `geth cas root add/list/scan/sync/apply`; root sync - local file-root commands: `geth cas root add/list/scan/sync/apply`; root sync
pulls authorized remote tree metadata and CAS tree bytes into a peer-qualified pulls authorized remote tree metadata and CAS tree bytes into a peer-qualified

View file

@ -6,8 +6,10 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
aes-gcm.workspace = true
blake3.workspace = true blake3.workspace = true
hex.workspace = true hex.workspace = true
rand.workspace = true
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
geth-codec = { path = "../geth-codec" } geth-codec = { path = "../geth-codec" }

View file

@ -4,7 +4,9 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub const CAS_TREE_OBJECT_VERSION: u16 = 1; pub const CAS_TREE_OBJECT_VERSION: u16 = 1;
pub const ENCRYPTED_BLOB_ENVELOPE_VERSION: u16 = 1; pub const ENCRYPTED_BLOB_ENVELOPE_VERSION: u16 = 2;
pub const PRIVATE_BLOB_AEAD_ALGORITHM: &str = "geth.aes-256-gcm.v1";
pub const PRIVATE_BLOB_PROTOTYPE_ALGORITHM: &str = "geth.blake3-xor.v0.prototype";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CasError { pub enum CasError {
@ -191,23 +193,50 @@ pub fn encrypt_private_blob(
resource: &str, resource: &str,
secret_id: &str, secret_id: &str,
epoch: u64, epoch: u64,
nonce_hex: &str,
plaintext: &[u8], plaintext: &[u8],
) -> Result<Vec<u8>, CasError> { ) -> Result<Vec<u8>, CasError> {
let nonce: [u8; 12] = rand::random();
encrypt_private_blob_with_nonce(resource, secret_id, epoch, nonce, plaintext)
}
fn encrypt_private_blob_with_nonce(
resource: &str,
secret_id: &str,
epoch: u64,
nonce: [u8; 12],
plaintext: &[u8],
) -> Result<Vec<u8>, CasError> {
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit, Payload},
};
let key = private_blob_key(resource, secret_id, epoch); let key = private_blob_key(resource, secret_id, epoch);
let nonce = decode_32_byte_hex(nonce_hex)?; let cipher = Aes256Gcm::new_from_slice(&key)
let ciphertext = xor_keystream(&key, &nonce, plaintext); .map_err(|error| CasError::EncryptedEnvelope(error.to_string()))?;
let tag_hex = private_blob_tag(&key, &nonce, &ciphertext); let plaintext_hash = hash_bytes(plaintext);
let aad = private_blob_aad(resource, epoch, &plaintext_hash);
let ciphertext = cipher
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: plaintext,
aad: aad.as_bytes(),
},
)
.map_err(|_| {
CasError::EncryptedEnvelope("private blob AEAD encryption failed".to_owned())
})?;
let envelope = EncryptedBlobEnvelope { let envelope = EncryptedBlobEnvelope {
version: ENCRYPTED_BLOB_ENVELOPE_VERSION, version: ENCRYPTED_BLOB_ENVELOPE_VERSION,
algorithm: "geth.blake3-xor.v0.prototype".to_owned(), algorithm: PRIVATE_BLOB_AEAD_ALGORITHM.to_owned(),
resource: resource.to_owned(), resource: resource.to_owned(),
epoch, epoch,
plaintext_hash: hash_bytes(plaintext), plaintext_hash,
nonce_hex: nonce_hex.to_owned(), nonce_hex: hex::encode(nonce),
ciphertext, ciphertext,
tag_hex, tag_hex: "included-in-aes-gcm-ciphertext".to_owned(),
note: "prototype private blob envelope; not an audited AEAD and does not claim forward secrecy or post-compromise security".to_owned(), note: "private blob envelope uses AES-256-GCM with a random 96-bit nonce and resource-bound associated data; it does not claim forward secrecy or post-compromise security".to_owned(),
}; };
geth_codec::encode_canonical(&envelope).map_err(CasError::from) geth_codec::encode_canonical(&envelope).map_err(CasError::from)
} }
@ -219,26 +248,53 @@ pub fn decrypt_private_blob(
) -> Result<Vec<u8>, CasError> { ) -> Result<Vec<u8>, CasError> {
let envelope: EncryptedBlobEnvelope = geth_codec::decode_canonical(envelope_bytes)?; let envelope: EncryptedBlobEnvelope = geth_codec::decode_canonical(envelope_bytes)?;
if envelope.version != ENCRYPTED_BLOB_ENVELOPE_VERSION { if envelope.version != ENCRYPTED_BLOB_ENVELOPE_VERSION {
if envelope.version == 1 || envelope.algorithm == PRIVATE_BLOB_PROTOTYPE_ALGORITHM {
return Err(CasError::EncryptedEnvelope(
"prototype private blob envelopes are no longer accepted; recreate the private blob with this pre-release".to_owned(),
));
}
return Err(CasError::EncryptedEnvelope(format!( return Err(CasError::EncryptedEnvelope(format!(
"unsupported envelope version {}", "unsupported envelope version {}",
envelope.version envelope.version
))); )));
} }
if envelope.algorithm != PRIVATE_BLOB_AEAD_ALGORITHM {
return Err(CasError::EncryptedEnvelope(format!(
"unsupported encrypted blob algorithm {}",
envelope.algorithm
)));
}
if envelope.resource != resource { if envelope.resource != resource {
return Err(CasError::EncryptedEnvelope(format!( return Err(CasError::EncryptedEnvelope(format!(
"envelope resource {} does not match requested resource {resource}", "envelope resource {} does not match requested resource {resource}",
envelope.resource envelope.resource
))); )));
} }
let key = private_blob_key(resource, secret_id, envelope.epoch); if envelope.tag_hex != "included-in-aes-gcm-ciphertext" {
let nonce = decode_32_byte_hex(&envelope.nonce_hex)?;
let expected_tag = private_blob_tag(&key, &nonce, &envelope.ciphertext);
if expected_tag != envelope.tag_hex {
return Err(CasError::EncryptedEnvelope( return Err(CasError::EncryptedEnvelope(
"encrypted blob tag verification failed".to_owned(), "invalid AES-GCM envelope tag marker".to_owned(),
)); ));
} }
let plaintext = xor_keystream(&key, &nonce, &envelope.ciphertext); use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit, Payload},
};
let key = private_blob_key(resource, secret_id, envelope.epoch);
let nonce = decode_hex_exact::<12>(&envelope.nonce_hex)?;
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|error| CasError::EncryptedEnvelope(error.to_string()))?;
let aad = private_blob_aad(resource, envelope.epoch, &envelope.plaintext_hash);
let plaintext = cipher
.decrypt(
Nonce::from_slice(&nonce),
Payload {
msg: envelope.ciphertext.as_ref(),
aad: aad.as_bytes(),
},
)
.map_err(|_| {
CasError::EncryptedEnvelope("private blob AEAD verification failed".to_owned())
})?;
let plaintext_hash = hash_bytes(&plaintext); let plaintext_hash = hash_bytes(&plaintext);
if plaintext_hash != envelope.plaintext_hash { if plaintext_hash != envelope.plaintext_hash {
return Err(CasError::EncryptedEnvelope( return Err(CasError::EncryptedEnvelope(
@ -249,42 +305,25 @@ pub fn decrypt_private_blob(
} }
fn private_blob_key(resource: &str, secret_id: &str, epoch: u64) -> [u8; 32] { fn private_blob_key(resource: &str, secret_id: &str, epoch: u64) -> [u8; 32] {
let material = format!("geth.private-blob.v0\0{resource}\0{secret_id}\0{epoch}"); let material = format!("geth.private-blob.aes-256-gcm.v1\0{resource}\0{secret_id}\0{epoch}");
*blake3::hash(material.as_bytes()).as_bytes() *blake3::hash(material.as_bytes()).as_bytes()
} }
fn xor_keystream(key: &[u8; 32], nonce: &[u8; 32], input: &[u8]) -> Vec<u8> { fn private_blob_aad(resource: &str, epoch: u64, plaintext_hash: &BlobHash) -> String {
let mut out = Vec::with_capacity(input.len()); format!(
for (chunk_index, chunk) in input.chunks(32).enumerate() { "{}\0{}\0{}\0{}",
let mut block_input = Vec::with_capacity(40); PRIVATE_BLOB_AEAD_ALGORITHM, resource, epoch, plaintext_hash
block_input.extend_from_slice(nonce); )
block_input.extend_from_slice(&(chunk_index as u64).to_le_bytes());
let block = blake3::keyed_hash(key, &block_input);
out.extend(
chunk
.iter()
.zip(block.as_bytes().iter())
.map(|(byte, mask)| byte ^ mask),
);
}
out
} }
fn private_blob_tag(key: &[u8; 32], nonce: &[u8; 32], ciphertext: &[u8]) -> String { fn decode_hex_exact<const N: usize>(hex: &str) -> Result<[u8; N], CasError> {
let mut input = Vec::with_capacity(nonce.len() + ciphertext.len()); if hex.len() != N * 2 {
input.extend_from_slice(nonce); return Err(CasError::EncryptedEnvelope(format!(
input.extend_from_slice(ciphertext); "nonce must be {N} bytes encoded as lowercase hex"
blake3::keyed_hash(key, &input).to_hex().to_string() )));
} }
let mut bytes = [0_u8; N];
fn decode_32_byte_hex(hex: &str) -> Result<[u8; 32], CasError> { for index in 0..N {
if hex.len() != 64 {
return Err(CasError::EncryptedEnvelope(
"nonce must be 32 bytes encoded as lowercase hex".to_owned(),
));
}
let mut bytes = [0_u8; 32];
for index in 0..32 {
bytes[index] = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16) bytes[index] = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16)
.map_err(|error| CasError::EncryptedEnvelope(format!("invalid nonce hex: {error}")))?; .map_err(|error| CasError::EncryptedEnvelope(format!("invalid nonce hex: {error}")))?;
} }
@ -769,11 +808,15 @@ mod tests {
} }
#[test] #[test]
fn private_blob_envelope_roundtrips_and_checks_resource() { fn private_blob_envelope_uses_aead_and_checks_bound_context() {
let plaintext = b"private geth bytes"; let plaintext = b"private geth bytes";
let nonce = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; let envelope = encrypt_private_blob_with_nonce(
let envelope = "resource:cas:private",
encrypt_private_blob("resource:cas:private", "secret:test", 1, nonce, plaintext) "secret:test",
1,
[7; 12],
plaintext,
)
.expect("encrypt"); .expect("encrypt");
let decrypted = decrypt_private_blob("resource:cas:private", "secret:test", &envelope) let decrypted = decrypt_private_blob("resource:cas:private", "secret:test", &envelope)
@ -781,6 +824,66 @@ mod tests {
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
assert!(decrypt_private_blob("resource:cas:other", "secret:test", &envelope).is_err()); assert!(decrypt_private_blob("resource:cas:other", "secret:test", &envelope).is_err());
assert!(decrypt_private_blob("resource:cas:private", "secret:other", &envelope).is_err()); assert!(decrypt_private_blob("resource:cas:private", "secret:other", &envelope).is_err());
let decoded: EncryptedBlobEnvelope =
geth_codec::decode_canonical(&envelope).expect("decode envelope");
assert_eq!(decoded.version, ENCRYPTED_BLOB_ENVELOPE_VERSION);
assert_eq!(decoded.algorithm, PRIVATE_BLOB_AEAD_ALGORITHM);
assert_eq!(decoded.nonce_hex, "070707070707070707070707");
assert_eq!(decoded.tag_hex, "included-in-aes-gcm-ciphertext");
let mut tampered_ciphertext = decoded.clone();
tampered_ciphertext.ciphertext[0] ^= 0x01;
let tampered_ciphertext =
geth_codec::encode_canonical(&tampered_ciphertext).expect("encode tampered");
assert!(
decrypt_private_blob("resource:cas:private", "secret:test", &tampered_ciphertext)
.is_err()
);
let mut tampered_hash = decoded.clone();
tampered_hash.plaintext_hash = hash_bytes(b"other plaintext");
let tampered_hash = geth_codec::encode_canonical(&tampered_hash).expect("encode tampered");
assert!(
decrypt_private_blob("resource:cas:private", "secret:test", &tampered_hash).is_err()
);
}
#[test]
fn private_blob_envelope_generates_unique_nonces() {
let first = encrypt_private_blob("resource:cas:private", "secret:test", 1, b"same")
.expect("first encrypt");
let second = encrypt_private_blob("resource:cas:private", "secret:test", 1, b"same")
.expect("second encrypt");
let first: EncryptedBlobEnvelope =
geth_codec::decode_canonical(&first).expect("decode first");
let second: EncryptedBlobEnvelope =
geth_codec::decode_canonical(&second).expect("decode second");
assert_ne!(first.nonce_hex, second.nonce_hex);
assert_ne!(first.ciphertext, second.ciphertext);
}
#[test]
fn prototype_private_blob_envelopes_are_rejected() {
let envelope = EncryptedBlobEnvelope {
version: 1,
algorithm: PRIVATE_BLOB_PROTOTYPE_ALGORITHM.to_owned(),
resource: "resource:cas:private".to_owned(),
epoch: 1,
plaintext_hash: hash_bytes(b"private geth bytes"),
nonce_hex: "00".repeat(32),
ciphertext: vec![1, 2, 3],
tag_hex: "prototype-tag".to_owned(),
note: "old prototype".to_owned(),
};
let encoded = geth_codec::encode_canonical(&envelope).expect("encode prototype");
let error = decrypt_private_blob("resource:cas:private", "secret:test", &encoded)
.expect_err("reject prototype");
assert!(
error
.to_string()
.contains("prototype private blob envelopes")
);
} }
#[test] #[test]

View file

@ -6174,21 +6174,10 @@ pub fn handle_request(
})?; })?;
let plaintext = std::fs::read(&path)?; let plaintext = std::fs::read(&path)?;
let plaintext_hash = geth_cas::hash_bytes(&plaintext); let plaintext_hash = geth_cas::hash_bytes(&plaintext);
let nonce = geth_crypto::blake3_hex(
format!(
"{}\0{}\0{}\0{}",
resource,
secret.secret_id,
secret.epoch,
geth_store::now_ms()
)
.as_bytes(),
);
let encrypted = geth_cas::encrypt_private_blob( let encrypted = geth_cas::encrypt_private_blob(
&resource, &resource,
&secret.secret_id, &secret.secret_id,
secret.epoch, secret.epoch,
&nonce,
&plaintext, &plaintext,
)?; )?;
let cas = LocalCas::new(node.paths.cas_dir()); let cas = LocalCas::new(node.paths.cas_dir());
@ -6204,7 +6193,7 @@ pub fn handle_request(
plaintext_hash, plaintext_hash,
encrypted_hash: info.hash, encrypted_hash: info.hash,
size_bytes: info.size_bytes, size_bytes: info.size_bytes,
note: "stored prototype encrypted private blob envelope; no forward secrecy or post-compromise security is claimed".to_owned(), note: "stored AES-256-GCM private blob envelope bound to the resource and secret epoch; no forward secrecy or post-compromise security is claimed".to_owned(),
}) })
} }
ControlRequest::CasGet { hash, out } => { ControlRequest::CasGet { hash, out } => {
@ -6258,7 +6247,9 @@ pub fn handle_request(
plaintext_hash: geth_cas::hash_bytes(&plaintext), plaintext_hash: geth_cas::hash_bytes(&plaintext),
out, out,
size_bytes: plaintext.len() as u64, size_bytes: plaintext.len() as u64,
note: "decrypted prototype private blob envelope with the latest local resource secret epoch".to_owned(), note:
"decrypted AES-256-GCM private blob envelope with a local resource secret epoch"
.to_owned(),
}) })
} }
ControlRequest::CasHash { path } => Ok(ControlResponse::CasHash { ControlRequest::CasHash { path } => Ok(ControlResponse::CasHash {

View file

@ -478,6 +478,10 @@ The project is structured for future multi-user local-first authorization:
## Keyhive/BeeKEM Roadmap ## Keyhive/BeeKEM Roadmap
Resource secret epochs are the v0/v1 approximation for private payload access. Resource secret epochs are the v0/v1 approximation for private payload access.
Private CAS writes use an AES-256-GCM envelope keyed from the resource, local
secret id, and epoch with resource-bound associated data and random nonces.
Prototype BLAKE3-XOR private blob envelopes from earlier pre-deployment builds
are rejected and should be recreated from plaintext.
Later designs can add Keyhive-like convergent capabilities and BeeKEM/CGKA-style Later designs can add Keyhive-like convergent capabilities and BeeKEM/CGKA-style
group key evolution. The bootstrap does not implement BeeKEM and does not claim group key evolution. The bootstrap does not implement BeeKEM and does not claim
strong forward secrecy or post-compromise security. strong forward secrecy or post-compromise security.

View file

@ -57,22 +57,17 @@ The following commands are tested but may still change while the subsystem
settles: settles:
- `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv` - `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv`
- `geth cas add-private`
- `geth cas get-private`
Migration expectation: overlay membership records and authorization resources Migration expectation: overlay membership records and authorization resources
should remain readable, but packet runtime flags, platform activation details, should remain readable, but packet runtime flags, platform activation details,
and route metadata may change before the first deployment tag. and route metadata may change before the first deployment tag.
Private CAS writes use an AES-256-GCM envelope, but the command family remains
## Prototype experimental while key envelopes, remote sharing, and forward-secrecy/PCS
properties are explicitly out of scope. Prototype BLAKE3-XOR envelopes from
The following commands intentionally do not claim a stable security or storage earlier pre-deployment builds are rejected with a clear error and should be
contract yet: recreated from plaintext.
- `geth cas add-private`
- `geth cas get-private`
Migration expectation: prototype private CAS envelopes may be replaced by an
audited AEAD/key-envelope design. Existing prototype blobs may need an explicit
re-encryption or export/import workflow.
## Adding Commands ## Adding Commands

View file

@ -191,22 +191,22 @@ Goal: finish the authorization and remote-input audit before deployment.
Goal: remove prototype cryptography from paths users may treat as real Goal: remove prototype cryptography from paths users may treat as real
confidential storage. confidential storage.
- `[ ]` Replace prototype private CAS envelope. - `[x]` Replace prototype private CAS envelope.
Acceptance criteria: Acceptance criteria:
- `[ ]` The BLAKE3-XOR prototype envelope is not used for new private CAS - `[x]` The BLAKE3-XOR prototype envelope is not used for new private CAS
writes. writes.
- `[ ]` New private CAS writes use a reviewed AEAD construction or an - `[x]` New private CAS writes use a reviewed AEAD construction or an
established envelope format such as age. established envelope format such as age.
- `[ ]` Key derivation, nonce generation, and envelope versioning are - `[x]` Key derivation, nonce generation, and envelope versioning are
documented. documented.
- `[ ]` Tests cover tamper detection, wrong resource, wrong key, and nonce - `[x]` Tests cover tamper detection, wrong resource, wrong key, and nonce
uniqueness behavior. uniqueness behavior.
- `[ ]` Define pre-release encrypted blob migration behavior. - `[x]` Define pre-release encrypted blob migration behavior.
Acceptance criteria: Acceptance criteria:
- `[ ]` Existing prototype envelopes are either rejected with a clear error - `[x]` Existing prototype envelopes are either rejected with a clear error
or migrated through an explicit command. or migrated through an explicit command.
- `[ ]` Docs state that prototype envelopes made before deployment are not a - `[x]` Docs state that prototype envelopes made before deployment are not a
durable security format. durable security format.
## Phase 6: Sync Correctness And Fault Testing ## Phase 6: Sync Correctness And Fault Testing
@ -343,7 +343,7 @@ Goal: prove the system works as an actual base layer before broader use.
3. `[x]` Add stable contract and golden JSON tests. 3. `[x]` Add stable contract and golden JSON tests.
4. `[x]` Harden store migrations and backup. 4. `[x]` Harden store migrations and backup.
5. `[ ]` Complete security-boundary test coverage. 5. `[ ]` Complete security-boundary test coverage.
6. `[ ]` Replace prototype private CAS cryptography. 6. `[x]` Replace prototype private CAS cryptography.
7. `[ ]` Add fault-injection sync tests. 7. `[ ]` Add fault-injection sync tests.
8. `[ ]` Improve automation commands and JSON errors. 8. `[ ]` Improve automation commands and JSON errors.
9. `[ ]` Add operational health, doctor, and release gates. 9. `[ ]` Add operational health, doctor, and release gates.

View file

@ -648,8 +648,12 @@ authorization and durable-state boundaries clear.
- `[x]` `geth cas get-private <resource> <hash> --out <path>` decrypts with - `[x]` `geth cas get-private <resource> <hash> --out <path>` decrypts with
a matching local resource secret epoch. a matching local resource secret epoch.
- `[x]` Access is gated by local resource secret epoch material. - `[x]` Access is gated by local resource secret epoch material.
- `[x]` Tests verify encrypted blob roundtrip and wrong resource/secret - `[x]` New writes use an AES-256-GCM envelope with resource-bound
rejection. associated data and random nonces.
- `[x]` Prototype BLAKE3-XOR envelopes from earlier pre-deployment builds are
rejected with a clear error.
- `[x]` Tests verify encrypted blob roundtrip, tamper detection, wrong
resource/secret rejection, old-envelope rejection, and nonce uniqueness.
- `[x]` Docs explicitly avoid claiming forward secrecy or PCS. - `[x]` Docs explicitly avoid claiming forward secrecy or PCS.
- `[x]` Iroh-docs KV integration. - `[x]` Iroh-docs KV integration.