mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
[ENG-331] StoredKey overhaul (#513)
* add wip storedkey versioning * storedkey versioning! (not pretty, but it never will be) * add version to `StoredKey` and re-gen migrations to handle serde * use `serde` for interacting with the DB + handle errors
This commit is contained in:
parent
9de6f00c1d
commit
3c0729e7aa
@ -0,0 +1,31 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `version` to the `key` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_key" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"version" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"date_created" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"algorithm" TEXT NOT NULL,
|
||||
"hashing_algorithm" TEXT NOT NULL,
|
||||
"content_salt" BLOB NOT NULL,
|
||||
"master_key" BLOB NOT NULL,
|
||||
"master_key_nonce" BLOB NOT NULL,
|
||||
"key_nonce" BLOB NOT NULL,
|
||||
"key" BLOB NOT NULL,
|
||||
"salt" BLOB NOT NULL,
|
||||
"automount" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_key" ("algorithm", "automount", "content_salt", "date_created", "default", "hashing_algorithm", "id", "key", "key_nonce", "master_key", "master_key_nonce", "name", "salt", "uuid") SELECT "algorithm", "automount", "content_salt", "date_created", "default", "hashing_algorithm", "id", "key", "key_nonce", "master_key", "master_key_nonce", "name", "salt", "uuid" FROM "key";
|
||||
DROP TABLE "key";
|
||||
ALTER TABLE "new_key" RENAME TO "key";
|
||||
CREATE UNIQUE INDEX "key_uuid_key" ON "key"("uuid");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@ -224,6 +224,7 @@ model Key {
|
||||
id Int @id @default(autoincrement())
|
||||
// uuid to identify the key
|
||||
uuid String @unique
|
||||
version String
|
||||
// the name that the user sets
|
||||
name String?
|
||||
// is this key the default for encryption?
|
||||
@ -233,9 +234,9 @@ model Key {
|
||||
// nullable if concealed for security
|
||||
date_created DateTime? @default(now())
|
||||
// encryption algorithm used to encrypt the key
|
||||
algorithm Bytes
|
||||
algorithm String
|
||||
// hashing algorithm used for hashing the key with the content salt
|
||||
hashing_algorithm Bytes
|
||||
hashing_algorithm String
|
||||
// salt used for encrypting data with this key
|
||||
content_salt Bytes
|
||||
// the *encrypted* master key (48 bytes)
|
||||
|
||||
@ -11,11 +11,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use sd_crypto::{
|
||||
crypto::stream::Algorithm,
|
||||
keys::{
|
||||
hashing::HashingAlgorithm,
|
||||
keymanager::{KeyManager, StoredKey},
|
||||
},
|
||||
keys::keymanager::{KeyManager, StoredKey},
|
||||
primitives::{to_array, OnboardingConfig},
|
||||
};
|
||||
use std::{
|
||||
@ -95,13 +91,17 @@ pub async fn seed_keymanager(
|
||||
|
||||
Ok(StoredKey {
|
||||
uuid,
|
||||
algorithm: Algorithm::from_bytes(to_array(key.algorithm)?)?,
|
||||
version: serde_json::from_str(&key.version)
|
||||
.map_err(|_| sd_crypto::Error::Serialization)?,
|
||||
algorithm: serde_json::from_str(&key.algorithm)
|
||||
.map_err(|_| sd_crypto::Error::Serialization)?,
|
||||
content_salt: to_array(key.content_salt)?,
|
||||
master_key: to_array(key.master_key)?,
|
||||
master_key_nonce: key.master_key_nonce,
|
||||
key_nonce: key.key_nonce,
|
||||
key: key.key,
|
||||
hashing_algorithm: HashingAlgorithm::from_bytes(to_array(key.hashing_algorithm)?)?,
|
||||
hashing_algorithm: serde_json::from_str(&key.hashing_algorithm)
|
||||
.map_err(|_| sd_crypto::Error::Serialization)?,
|
||||
salt: to_array(key.salt)?,
|
||||
memory_only: false,
|
||||
automount: key.automount,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use crate::library::LibraryManagerError;
|
||||
use crate::prisma::{self, PrismaClient};
|
||||
use prisma_client_rust::QueryError;
|
||||
use prisma_client_rust::{migrations::*, NewClientError};
|
||||
use sd_crypto::keys::keymanager::StoredKey;
|
||||
use thiserror::Error;
|
||||
@ -45,13 +45,17 @@ pub async fn load_and_migrate(db_url: &str) -> Result<PrismaClient, MigrationErr
|
||||
|
||||
/// This writes a `StoredKey` to prisma
|
||||
/// If the key is marked as memory-only, it is skipped
|
||||
pub async fn write_storedkey_to_db(db: &PrismaClient, key: &StoredKey) -> Result<(), QueryError> {
|
||||
pub async fn write_storedkey_to_db(
|
||||
db: &PrismaClient,
|
||||
key: &StoredKey,
|
||||
) -> Result<(), LibraryManagerError> {
|
||||
if !key.memory_only {
|
||||
db.key()
|
||||
.create(
|
||||
key.uuid.to_string(),
|
||||
key.algorithm.to_bytes().to_vec(),
|
||||
key.hashing_algorithm.to_bytes().to_vec(),
|
||||
serde_json::to_string(&key.version)?,
|
||||
serde_json::to_string(&key.algorithm)?,
|
||||
serde_json::to_string(&key.hashing_algorithm)?,
|
||||
key.content_salt.to_vec(),
|
||||
key.master_key.to_vec(),
|
||||
key.master_key_nonce.to_vec(),
|
||||
|
||||
@ -40,7 +40,7 @@ use std::sync::Mutex;
|
||||
use crate::crypto::stream::{StreamDecryption, StreamEncryption};
|
||||
use crate::primitives::{
|
||||
derive_key, generate_master_key, generate_nonce, generate_salt, to_array, OnboardingConfig,
|
||||
KEY_LEN, MASTER_PASSWORD_CONTEXT, ROOT_KEY_CONTEXT,
|
||||
KEY_LEN, LATEST_STORED_KEY, MASTER_PASSWORD_CONTEXT, ROOT_KEY_CONTEXT,
|
||||
};
|
||||
use crate::{
|
||||
crypto::stream::Algorithm,
|
||||
@ -62,7 +62,8 @@ use super::hashing::HashingAlgorithm;
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "rspc", derive(specta::Type))]
|
||||
pub struct StoredKey {
|
||||
pub uuid: uuid::Uuid, // uuid for identification. shared with mounted keys
|
||||
pub uuid: uuid::Uuid, // uuid for identification. shared with mounted keys
|
||||
pub version: StoredKeyVersion,
|
||||
pub algorithm: Algorithm, // encryption algorithm for encrypting the master key. can be changed (requires a re-encryption though)
|
||||
pub hashing_algorithm: HashingAlgorithm, // hashing algorithm used for hashing the key with the content salt
|
||||
pub content_salt: [u8; SALT_LEN],
|
||||
@ -76,6 +77,13 @@ pub struct StoredKey {
|
||||
pub automount: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "rspc", derive(specta::Type))]
|
||||
pub enum StoredKeyVersion {
|
||||
V1,
|
||||
}
|
||||
|
||||
/// This is a mounted key, and needs to be kept somewhat hidden.
|
||||
///
|
||||
/// This contains the plaintext key, and the same key hashed with the content salt.
|
||||
@ -168,6 +176,7 @@ impl KeyManager {
|
||||
|
||||
let verification_key = StoredKey {
|
||||
uuid,
|
||||
version: LATEST_STORED_KEY,
|
||||
algorithm,
|
||||
hashing_algorithm,
|
||||
content_salt, // salt used for hashing
|
||||
@ -274,6 +283,7 @@ impl KeyManager {
|
||||
|
||||
let verification_key = StoredKey {
|
||||
uuid,
|
||||
version: LATEST_STORED_KEY,
|
||||
algorithm,
|
||||
hashing_algorithm,
|
||||
content_salt,
|
||||
@ -320,35 +330,39 @@ impl KeyManager {
|
||||
|
||||
let old_verification_key = old_verification_key.ok_or(Error::NoVerificationKey)?;
|
||||
|
||||
let hashed_password = old_verification_key.hashing_algorithm.hash(
|
||||
Protected::new(master_password.expose().as_bytes().to_vec()),
|
||||
old_verification_key.content_salt,
|
||||
secret_key,
|
||||
)?;
|
||||
let old_root_key = match old_verification_key.version {
|
||||
StoredKeyVersion::V1 => {
|
||||
let hashed_password = old_verification_key.hashing_algorithm.hash(
|
||||
Protected::new(master_password.expose().as_bytes().to_vec()),
|
||||
old_verification_key.content_salt,
|
||||
secret_key,
|
||||
)?;
|
||||
|
||||
// decrypt the root key's KEK
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(
|
||||
hashed_password,
|
||||
old_verification_key.salt,
|
||||
MASTER_PASSWORD_CONTEXT,
|
||||
),
|
||||
&old_verification_key.master_key_nonce,
|
||||
old_verification_key.algorithm,
|
||||
&old_verification_key.master_key,
|
||||
&[],
|
||||
)?;
|
||||
// decrypt the root key's KEK
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(
|
||||
hashed_password,
|
||||
old_verification_key.salt,
|
||||
MASTER_PASSWORD_CONTEXT,
|
||||
),
|
||||
&old_verification_key.master_key_nonce,
|
||||
old_verification_key.algorithm,
|
||||
&old_verification_key.master_key,
|
||||
&[],
|
||||
)?;
|
||||
|
||||
// get the root key from the backup
|
||||
let old_root_key = StreamDecryption::decrypt_bytes(
|
||||
Protected::new(to_array(master_key.into_inner())?),
|
||||
&old_verification_key.key_nonce,
|
||||
old_verification_key.algorithm,
|
||||
&old_verification_key.key,
|
||||
&[],
|
||||
)?;
|
||||
// get the root key from the backup
|
||||
let old_root_key = StreamDecryption::decrypt_bytes(
|
||||
Protected::new(to_array(master_key.into_inner())?),
|
||||
&old_verification_key.key_nonce,
|
||||
old_verification_key.algorithm,
|
||||
&old_verification_key.key,
|
||||
&[],
|
||||
)?;
|
||||
|
||||
let old_root_key = Protected::new(to_array(old_root_key.into_inner())?);
|
||||
Protected::new(to_array(old_root_key.into_inner())?)
|
||||
}
|
||||
};
|
||||
|
||||
let mut reencrypted_keys = Vec::new();
|
||||
|
||||
@ -357,39 +371,43 @@ impl KeyManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// decrypt the key's master key
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(old_root_key.clone(), key.salt, ROOT_KEY_CONTEXT),
|
||||
&key.master_key_nonce,
|
||||
key.algorithm,
|
||||
&key.master_key,
|
||||
&[],
|
||||
)
|
||||
.map_or(Err(Error::IncorrectPassword), |v| {
|
||||
Ok(Protected::new(to_array::<KEY_LEN>(v.into_inner())?))
|
||||
})?;
|
||||
match key.version {
|
||||
StoredKeyVersion::V1 => {
|
||||
// decrypt the key's master key
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(old_root_key.clone(), key.salt, ROOT_KEY_CONTEXT),
|
||||
&key.master_key_nonce,
|
||||
key.algorithm,
|
||||
&key.master_key,
|
||||
&[],
|
||||
)
|
||||
.map_or(Err(Error::IncorrectPassword), |v| {
|
||||
Ok(Protected::new(to_array::<KEY_LEN>(v.into_inner())?))
|
||||
})?;
|
||||
|
||||
// generate a new nonce
|
||||
let master_key_nonce = generate_nonce(key.algorithm);
|
||||
// generate a new nonce
|
||||
let master_key_nonce = generate_nonce(key.algorithm);
|
||||
|
||||
let salt = generate_salt();
|
||||
let salt = generate_salt();
|
||||
|
||||
// encrypt the master key with the current root key
|
||||
let encrypted_master_key = to_array(StreamEncryption::encrypt_bytes(
|
||||
derive_key(self.get_root_key()?, salt, ROOT_KEY_CONTEXT),
|
||||
&master_key_nonce,
|
||||
key.algorithm,
|
||||
master_key.expose(),
|
||||
&[],
|
||||
)?)?;
|
||||
// encrypt the master key with the current root key
|
||||
let encrypted_master_key = to_array(StreamEncryption::encrypt_bytes(
|
||||
derive_key(self.get_root_key()?, salt, ROOT_KEY_CONTEXT),
|
||||
&master_key_nonce,
|
||||
key.algorithm,
|
||||
master_key.expose(),
|
||||
&[],
|
||||
)?)?;
|
||||
|
||||
let mut updated_key = key.clone();
|
||||
updated_key.master_key_nonce = master_key_nonce;
|
||||
updated_key.master_key = encrypted_master_key;
|
||||
updated_key.salt = salt;
|
||||
let mut updated_key = key.clone();
|
||||
updated_key.master_key_nonce = master_key_nonce;
|
||||
updated_key.master_key = encrypted_master_key;
|
||||
updated_key.salt = salt;
|
||||
|
||||
reencrypted_keys.push(updated_key.clone());
|
||||
self.keystore.insert(updated_key.uuid, updated_key);
|
||||
reencrypted_keys.push(updated_key.clone());
|
||||
self.keystore.insert(updated_key.uuid, updated_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(reencrypted_keys)
|
||||
@ -413,37 +431,40 @@ impl KeyManager {
|
||||
|
||||
let secret_key = secret_key.map(Self::convert_secret_key_string);
|
||||
|
||||
let hashed_password = verification_key.hashing_algorithm.hash(
|
||||
Protected::new(master_password.expose().as_bytes().to_vec()),
|
||||
verification_key.content_salt,
|
||||
secret_key,
|
||||
)?;
|
||||
match verification_key.version {
|
||||
StoredKeyVersion::V1 => {
|
||||
let hashed_password = verification_key.hashing_algorithm.hash(
|
||||
Protected::new(master_password.expose().as_bytes().to_vec()),
|
||||
verification_key.content_salt,
|
||||
secret_key,
|
||||
)?;
|
||||
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(
|
||||
hashed_password,
|
||||
verification_key.salt,
|
||||
MASTER_PASSWORD_CONTEXT,
|
||||
),
|
||||
&verification_key.master_key_nonce,
|
||||
verification_key.algorithm,
|
||||
&verification_key.master_key,
|
||||
&[],
|
||||
)
|
||||
.map_err(|_| Error::IncorrectKeymanagerDetails)?;
|
||||
|
||||
*self.root_key.lock()? = Some(Protected::new(to_array(
|
||||
StreamDecryption::decrypt_bytes(
|
||||
Protected::new(to_array(master_key.into_inner())?),
|
||||
&verification_key.key_nonce,
|
||||
verification_key.algorithm,
|
||||
&verification_key.key,
|
||||
&[],
|
||||
)?
|
||||
.expose()
|
||||
.clone(),
|
||||
)?));
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(
|
||||
hashed_password,
|
||||
verification_key.salt,
|
||||
MASTER_PASSWORD_CONTEXT,
|
||||
),
|
||||
&verification_key.master_key_nonce,
|
||||
verification_key.algorithm,
|
||||
&verification_key.master_key,
|
||||
&[],
|
||||
)
|
||||
.map_err(|_| Error::IncorrectKeymanagerDetails)?;
|
||||
|
||||
*self.root_key.lock()? = Some(Protected::new(to_array(
|
||||
StreamDecryption::decrypt_bytes(
|
||||
Protected::new(to_array(master_key.into_inner())?),
|
||||
&verification_key.key_nonce,
|
||||
verification_key.algorithm,
|
||||
&verification_key.key,
|
||||
&[],
|
||||
)?
|
||||
.expose()
|
||||
.clone(),
|
||||
)?));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -459,55 +480,58 @@ impl KeyManager {
|
||||
return Err(Error::KeyAlreadyMounted);
|
||||
}
|
||||
|
||||
match self.keystore.get(&uuid) {
|
||||
Some(stored_key) => {
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(self.get_root_key()?, stored_key.salt, ROOT_KEY_CONTEXT),
|
||||
&stored_key.master_key_nonce,
|
||||
stored_key.algorithm,
|
||||
&stored_key.master_key,
|
||||
&[],
|
||||
)
|
||||
.map_or(Err(Error::IncorrectPassword), |v| {
|
||||
Ok(Protected::new(to_array(v.into_inner())?))
|
||||
})?;
|
||||
self.keystore
|
||||
.get(&uuid)
|
||||
.map_or(Err(Error::KeyNotFound), |stored_key| {
|
||||
match stored_key.version {
|
||||
StoredKeyVersion::V1 => {
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(self.get_root_key()?, stored_key.salt, ROOT_KEY_CONTEXT),
|
||||
&stored_key.master_key_nonce,
|
||||
stored_key.algorithm,
|
||||
&stored_key.master_key,
|
||||
&[],
|
||||
)
|
||||
.map_or(Err(Error::IncorrectPassword), |v| {
|
||||
Ok(Protected::new(to_array(v.into_inner())?))
|
||||
})?;
|
||||
// Decrypt the StoredKey using the decrypted master key
|
||||
let key = StreamDecryption::decrypt_bytes(
|
||||
master_key,
|
||||
&stored_key.key_nonce,
|
||||
stored_key.algorithm,
|
||||
&stored_key.key,
|
||||
&[],
|
||||
)?;
|
||||
|
||||
// Decrypt the StoredKey using the decrypted master key
|
||||
let key = StreamDecryption::decrypt_bytes(
|
||||
master_key,
|
||||
&stored_key.key_nonce,
|
||||
stored_key.algorithm,
|
||||
&stored_key.key,
|
||||
&[],
|
||||
)?;
|
||||
// Hash the key once with the parameters/algorithm the user selected during first mount
|
||||
let hashed_key = stored_key.hashing_algorithm.hash(
|
||||
key,
|
||||
stored_key.content_salt,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Hash the key once with the parameters/algorithm the user selected during first mount
|
||||
let hashed_key =
|
||||
stored_key
|
||||
.hashing_algorithm
|
||||
.hash(key, stored_key.content_salt, None)?;
|
||||
self.keymount.insert(
|
||||
uuid,
|
||||
MountedKey {
|
||||
uuid: stored_key.uuid,
|
||||
hashed_key,
|
||||
},
|
||||
);
|
||||
|
||||
self.keymount.insert(
|
||||
uuid,
|
||||
MountedKey {
|
||||
uuid: stored_key.uuid,
|
||||
hashed_key,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
None => Err(Error::KeyNotFound),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This function is used for getting the key value itself, from a given UUID.
|
||||
///
|
||||
/// The master password/salt needs to be present, so we are able to decrypt the key itself from the stored key.
|
||||
pub fn get_key(&self, uuid: Uuid) -> Result<Protected<Vec<u8>>> {
|
||||
self.keystore.get(&uuid).map_or_else(
|
||||
|| Err(Error::KeyNotFound),
|
||||
|stored_key| {
|
||||
self.keystore
|
||||
.get(&uuid)
|
||||
.map_or(Err(Error::KeyNotFound), |stored_key| {
|
||||
let master_key = StreamDecryption::decrypt_bytes(
|
||||
derive_key(self.get_root_key()?, stored_key.salt, ROOT_KEY_CONTEXT),
|
||||
&stored_key.master_key_nonce,
|
||||
@ -529,8 +553,7 @@ impl KeyManager {
|
||||
)?;
|
||||
|
||||
Ok(key)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// This function is used to add a new key/password to the keystore.
|
||||
@ -584,6 +607,7 @@ impl KeyManager {
|
||||
uuid,
|
||||
StoredKey {
|
||||
uuid,
|
||||
version: LATEST_STORED_KEY,
|
||||
algorithm,
|
||||
hashing_algorithm,
|
||||
content_salt,
|
||||
|
||||
@ -11,7 +11,7 @@ use crate::{
|
||||
file::FileHeaderVersion, keyslot::KeyslotVersion, metadata::MetadataVersion,
|
||||
preview_media::PreviewMediaVersion,
|
||||
},
|
||||
keys::hashing::HashingAlgorithm,
|
||||
keys::{hashing::HashingAlgorithm, keymanager::StoredKeyVersion},
|
||||
Error, Protected, Result,
|
||||
};
|
||||
|
||||
@ -37,6 +37,7 @@ pub const LATEST_FILE_HEADER: FileHeaderVersion = FileHeaderVersion::V1;
|
||||
pub const LATEST_KEYSLOT: KeyslotVersion = KeyslotVersion::V1;
|
||||
pub const LATEST_METADATA: MetadataVersion = MetadataVersion::V1;
|
||||
pub const LATEST_PREVIEW_MEDIA: PreviewMediaVersion = PreviewMediaVersion::V1;
|
||||
pub const LATEST_STORED_KEY: StoredKeyVersion = StoredKeyVersion::V1;
|
||||
|
||||
pub const ROOT_KEY_CONTEXT: &str = "spacedrive 2022-12-14 12:53:54 root key derivation"; // used for deriving keys from the root key
|
||||
pub const MASTER_PASSWORD_CONTEXT: &str =
|
||||
|
||||
@ -169,7 +169,9 @@ export interface SetNoteArgs { id: number, note: string | null }
|
||||
|
||||
export interface Statistics { id: number, date_captured: string, total_object_count: number, library_db_size: string, total_bytes_used: string, total_bytes_capacity: string, total_unique_bytes: string, total_bytes_free: string, preview_media_bytes: string }
|
||||
|
||||
export interface StoredKey { uuid: string, algorithm: Algorithm, hashing_algorithm: HashingAlgorithm, content_salt: Array<number>, master_key: Array<number>, master_key_nonce: Array<number>, key_nonce: Array<number>, key: Array<number>, salt: Array<number>, memory_only: boolean, automount: boolean }
|
||||
export interface StoredKey { uuid: string, version: StoredKeyVersion, algorithm: Algorithm, hashing_algorithm: HashingAlgorithm, content_salt: Array<number>, master_key: Array<number>, master_key_nonce: Array<number>, key_nonce: Array<number>, key: Array<number>, salt: Array<number>, memory_only: boolean, automount: boolean }
|
||||
|
||||
export type StoredKeyVersion = "V1"
|
||||
|
||||
export interface Tag { id: number, pub_id: Array<number>, name: string | null, color: string | null, total_objects: number | null, redundancy_goal: number | null, date_created: string, date_modified: string }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user