605 lines
20 KiB
TypeScript
605 lines
20 KiB
TypeScript
import { stringToBytes, Serializable, concatBytes, AsSerializable, AsBytes, AsDate, Exclude } from "./encoding";
|
|
import { RSAPrivateKey, RSAPublicKey, AESKey, RSAKeyParams, AESKeyParams, RSASigningParams } from "./crypto";
|
|
import { getCryptoProvider as getProvider } from "./platform";
|
|
import { SharedContainer } from "./container";
|
|
import { Err, ErrorCode } from "./error";
|
|
import { Storable } from "./storage";
|
|
import { Vault, VaultID } from "./vault";
|
|
import { Account, AccountID, UnlockedAccount } from "./account";
|
|
import { Invite, InviteID } from "./invite";
|
|
|
|
/** Role of a member within an organization, each associated with certain priviliges */
|
|
export enum OrgRole {
|
|
/**
|
|
* Organization owner. Can manage members, groups and vaults. Owners have
|
|
* access to the secret [[Org.privateKey]] and [[Org.invitesKey]]
|
|
* properties.
|
|
*/
|
|
Owner,
|
|
/**
|
|
* Organization admin. Can manage groups and vaults.
|
|
*/
|
|
Admin,
|
|
/**
|
|
* Basic organization member. Can read public organization data and read/write
|
|
* certain [[Vault]]s they have been assigned to directly or via [[Group]]s.
|
|
*/
|
|
Member,
|
|
/**
|
|
* Suspended members can read public organization data and access [[Vaults]] they
|
|
* have been assigned to, but are excluded from any updates to those vaults.
|
|
* Member information (like public key and email address) of suspended members
|
|
* are considered unverified, and need to be updated and verified via a
|
|
* membership confirmation [[Invite]].
|
|
*/
|
|
Suspended,
|
|
}
|
|
|
|
/**
|
|
* Represents an [[Account]]s membership to an [[Org]]
|
|
*/
|
|
export class OrgMember extends Serializable {
|
|
/** id of the corresponding [[Account]] */
|
|
id: AccountID = "";
|
|
|
|
/** name of the corresponding [[Account]] */
|
|
name = "";
|
|
|
|
/** email address of the corresponding [[Account]] */
|
|
email = "";
|
|
|
|
/** public key of the corresponding [[Account]] */
|
|
@AsBytes()
|
|
publicKey!: RSAPublicKey;
|
|
|
|
/** signature used by other members to verify [[id]], [[email]] and [[publicKey]] */
|
|
@AsBytes()
|
|
signature!: Uint8Array;
|
|
|
|
/** signature used by the member to verify [[Org.id]] and [[Org.publickey]] of the organization */
|
|
@AsBytes()
|
|
orgSignature!: Uint8Array;
|
|
|
|
/** vaults assigned to this member */
|
|
vaults: {
|
|
id: VaultID;
|
|
readonly: boolean;
|
|
}[] = [];
|
|
|
|
/** the members organization role */
|
|
role: OrgRole = OrgRole.Member;
|
|
|
|
/** time the member was last updated */
|
|
@AsDate()
|
|
updated = new Date(0);
|
|
|
|
constructor({ id, name, email, publicKey, signature, orgSignature, role, updated }: Partial<OrgMember> = {}) {
|
|
super();
|
|
Object.assign(this, { id, name, email, publicKey, signature, orgSignature, updated });
|
|
this.role = typeof role !== "undefined" && role in OrgRole ? role : OrgRole.Member;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A group of members, used to manage [[Vault]] access for multiple members at once.
|
|
*/
|
|
export class Group extends Serializable {
|
|
constructor(vals: Partial<Group> = {}) {
|
|
super();
|
|
Object.assign(this, vals);
|
|
}
|
|
|
|
/** display name */
|
|
name = "";
|
|
/** members assigned to this group */
|
|
members: { id: AccountID }[] = [];
|
|
/** [[Vault]]s assigned to this group */
|
|
vaults: {
|
|
id: VaultID;
|
|
readonly: boolean;
|
|
}[] = [];
|
|
}
|
|
|
|
/** Unique identifier for [[Org]]s */
|
|
export type OrgID = string;
|
|
|
|
export class OrgSecrets extends Serializable {
|
|
constructor({ invitesKey, privateKey }: Partial<OrgSecrets> = {}) {
|
|
super();
|
|
Object.assign(this, { invitesKey, privateKey });
|
|
}
|
|
|
|
@AsBytes()
|
|
invitesKey!: Uint8Array;
|
|
|
|
@AsBytes()
|
|
privateKey!: Uint8Array;
|
|
}
|
|
|
|
export interface OrgInfo {
|
|
id: OrgID;
|
|
name: string;
|
|
owner: AccountID;
|
|
revision: string;
|
|
}
|
|
|
|
/**
|
|
* Organizations are the central component of Padlocs secure data sharing architecture.
|
|
*
|
|
* All shared [[Vault]]s are provisioned and managed in the context of an organization,
|
|
* while the [[Org]] class itself is responsible for managing, signing and verifying
|
|
* public keys, identities and priviliges for all of it's members.
|
|
*
|
|
* Vaults can be assigned to members direcly or indirectly through [[Group]]s. In both
|
|
* cases, this access can be declared *readonly*.
|
|
*
|
|
* Before being added to an organization, members need to go throug a key exchange
|
|
* procedure designed to allow verification of organization and member details
|
|
* by both parties. See [[Invite]] class for details.
|
|
*
|
|
* The [[privateKey]] and [[invitesKey]] properties are considered secret and are only
|
|
* accessible to members with the [[OrgRole.Owner]] role. To protect this information
|
|
* from unauthorized access, [[Org]] extends the [[SharedContainer]] class, encrypting
|
|
* this data at rest.
|
|
*
|
|
* #### Organization Structure
|
|
* ```
|
|
* ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
* │ │ ╱│ │╲ │ │
|
|
* │ Account │┼─────────○─│ Membership │──┼────────┼│ Organization │
|
|
* │ │ ╲│ │╱ │ │
|
|
* └──────────────┘ └───┬──────┬───┘ └──────────────┘
|
|
* ╲│╱ ╲│╱ ┼
|
|
* ○ ○ ○
|
|
* │ │ ╱│╲
|
|
* │ │ ┌──────────────┐
|
|
* │ │ ╱│ │
|
|
* │ └──────────────○─│ Group │
|
|
* │ ╲│ │
|
|
* ○ └──────────────┘
|
|
* ╱│╲ ╲│╱
|
|
* ┌──────────────┐ ○
|
|
* │ │╲ │
|
|
* │ Shared Vault │─○──────────────────────┘
|
|
* │ │╱
|
|
* └──────────────┘
|
|
* ```
|
|
*/
|
|
export class Org extends SharedContainer implements Storable {
|
|
/** Unique identier */
|
|
id: OrgID = "";
|
|
|
|
/** [[Account]] which created this organization */
|
|
owner: AccountID = "";
|
|
|
|
/** Organization name */
|
|
name: string = "";
|
|
|
|
/** Creation date */
|
|
@AsDate()
|
|
created: Date = new Date();
|
|
|
|
/** Last updated */
|
|
@AsDate()
|
|
updated: Date = new Date();
|
|
|
|
/** Public key used for verifying member signatures */
|
|
@AsBytes()
|
|
publicKey!: RSAPublicKey;
|
|
|
|
/**
|
|
* Private key used for signing member details
|
|
*
|
|
* @secret
|
|
* **IMPORTANT**: This property is considered **secret**
|
|
* and should never stored or transmitted in plain text
|
|
*/
|
|
@Exclude()
|
|
privateKey?: RSAPrivateKey;
|
|
|
|
/**
|
|
* AES key used as encryption key for [[Invite]]s
|
|
*
|
|
* @secret
|
|
* **IMPORTANT**: This property is considered **secret**
|
|
* and should never stored or transmitted in plain text
|
|
*/
|
|
@Exclude()
|
|
invitesKey?: AESKey;
|
|
|
|
/**
|
|
* Minimum accepted update time for organization members.
|
|
* Any members with a [[OrgMember.updated]] value lower than
|
|
* this should be considered invalid.
|
|
*
|
|
* In order to prevent an attacker from rolling back this value, all
|
|
* clients should verify that updated organization object always have a
|
|
* [[Org.minMemberUpdated]] value equal to or higher than the previous one.
|
|
*/
|
|
@AsDate()
|
|
minMemberUpdated: Date = new Date();
|
|
|
|
/** Parameters for creating member signatures */
|
|
@AsSerializable(RSASigningParams)
|
|
signingParams = new RSASigningParams();
|
|
|
|
/** Array of organization members */
|
|
@AsSerializable(OrgMember)
|
|
members: OrgMember[] = [];
|
|
|
|
/** This organizations [[Group]]s. */
|
|
@AsSerializable(Group)
|
|
groups: Group[] = [];
|
|
|
|
/** Shared [[Vault]]s owned by this organization */
|
|
vaults: { id: VaultID; name: string; revision?: string }[] = [];
|
|
|
|
/** Pending [[Invite]]s */
|
|
@AsSerializable(Invite)
|
|
invites: Invite[] = [];
|
|
|
|
/**
|
|
* Revision id used for ensuring continuity when synchronizing the account
|
|
* object between client and server
|
|
*/
|
|
revision: string = "";
|
|
|
|
get info(): OrgInfo {
|
|
return {
|
|
id: this.id,
|
|
name: this.name,
|
|
owner: this.owner,
|
|
revision: this.revision,
|
|
};
|
|
}
|
|
|
|
/** Whether the given [[Account]] is an [[OrgRole.Owner]] */
|
|
isOwner({ id }: { id: AccountID }) {
|
|
return this.owner === id;
|
|
}
|
|
|
|
/** Whether the given [[Account]] is an [[OrgRole.Admin]] */
|
|
isAdmin(m: { id: AccountID }) {
|
|
const member = this.getMember(m);
|
|
return !!member && member.role <= OrgRole.Admin;
|
|
}
|
|
|
|
/** Whether the given [[Account]] is currently suspended */
|
|
isSuspended(m: { id: AccountID }) {
|
|
const member = this.getMember(m);
|
|
return !!member && member.role === OrgRole.Suspended;
|
|
}
|
|
|
|
/** Get the [[OrgMember]] object for this [[Account]] */
|
|
getMember({ id }: { id: AccountID }) {
|
|
return this.members.find((m) => m.id === id);
|
|
}
|
|
|
|
/** Whether the given [[Account]] is an organization member */
|
|
isMember(acc: { id: AccountID }) {
|
|
return !!this.getMember(acc);
|
|
}
|
|
|
|
/** Get group with the given `name` */
|
|
getGroup(name: string) {
|
|
return [...this.groups].find((g) => g.name === name);
|
|
}
|
|
|
|
/** Get all members of a given `group` */
|
|
getMembersForGroup(group: Group): OrgMember[] {
|
|
return (
|
|
group.members
|
|
.map((m) => this.getMember(m))
|
|
// Filter out undefined members
|
|
.filter((m) => !!m) as OrgMember[]
|
|
);
|
|
}
|
|
|
|
/** Get all [[Group]]s the given [[Account]] is a member of */
|
|
getGroupsForMember({ id }: { id: AccountID }) {
|
|
return this.groups.filter((g) => g.members.some((m) => m.id === id));
|
|
}
|
|
|
|
/** Get all groups assigned to a given [[Vault]] */
|
|
getGroupsForVault({ id }: { id: VaultID }): Group[] {
|
|
return this.groups.filter((group) => group.vaults.some((v) => v.id === id));
|
|
}
|
|
|
|
/** Get all members directly assigned to a given [[Vault]] */
|
|
getMembersForVault({ id }: { id: VaultID }): OrgMember[] {
|
|
return this.members.filter(
|
|
(member) => member.role !== OrgRole.Suspended && member.vaults.some((v) => v.id === id)
|
|
);
|
|
}
|
|
|
|
/** Get all membes that have acess to a given `vault`, either directly or through a [[Group]] */
|
|
getAccessors(vault: Vault) {
|
|
const results = new Set<OrgMember>(this.getMembersForVault(vault));
|
|
|
|
for (const group of this.getGroupsForVault(vault)) {
|
|
for (const m of group.members) {
|
|
const member = this.getMember(m);
|
|
if (member && member.role !== OrgRole.Suspended) {
|
|
results.add(member);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...results];
|
|
}
|
|
|
|
/** Get all vaults the given member has access to */
|
|
getVaultsForMember(acc: OrgMember | Account) {
|
|
const member = this.getMember(acc);
|
|
|
|
if (!member) {
|
|
return [];
|
|
}
|
|
|
|
const results = new Set<VaultID>(member.vaults.map((v) => v.id));
|
|
|
|
for (const group of this.getGroupsForMember(member)) {
|
|
for (const vault of group.vaults) {
|
|
results.add(vault.id);
|
|
}
|
|
}
|
|
|
|
return this.vaults.filter((v) => results.has(v.id));
|
|
}
|
|
|
|
/** Check whether the given `account` has read access to a `vault` */
|
|
canRead(vault: { id: VaultID }, account: { id: AccountID }) {
|
|
const member = this.getMember(account);
|
|
|
|
return (
|
|
member &&
|
|
[member, ...this.getGroupsForMember(member)].some(({ vaults }) => vaults.some((v) => v.id === vault.id))
|
|
);
|
|
}
|
|
|
|
/** Check whether the given `account` has write access to a `vault` */
|
|
canWrite(vault: { id: VaultID }, acc: { id: AccountID }) {
|
|
const member = this.getMember(acc);
|
|
|
|
return (
|
|
member &&
|
|
member.role !== OrgRole.Suspended &&
|
|
[member, ...this.getGroupsForMember(member)].some(({ vaults }) =>
|
|
vaults.some((v) => v.id === vault.id && !v.readonly)
|
|
)
|
|
);
|
|
}
|
|
|
|
/** Get the invite with the given `id` */
|
|
getInvite(id: InviteID) {
|
|
return this.invites.find((inv) => inv.id === id);
|
|
}
|
|
|
|
/** Remove an invite */
|
|
removeInvite({ id }: Invite) {
|
|
this.invites = this.invites.filter((inv) => inv.id !== id);
|
|
}
|
|
|
|
/**
|
|
* Initializes the organization, generating [[publicKey]], [[privateKey]],
|
|
* and [[invitesKey]] and adding the given `account` as the organization
|
|
* owner.
|
|
*/
|
|
async initialize(account: Account) {
|
|
// Update access to keypair
|
|
await this.updateAccessors([account]);
|
|
|
|
// Generate cryptographic keys
|
|
await this.generateKeys();
|
|
|
|
// Set minimum date for member update times
|
|
this.minMemberUpdated = new Date();
|
|
|
|
const orgSignature = await account.signOrg(this);
|
|
const member = await this.sign(
|
|
new OrgMember({
|
|
id: account.id,
|
|
name: account.name,
|
|
email: account.email,
|
|
publicKey: account.publicKey,
|
|
orgSignature,
|
|
role: OrgRole.Owner,
|
|
updated: new Date(),
|
|
})
|
|
);
|
|
this.members.push(member);
|
|
}
|
|
|
|
/**
|
|
* Generates a new [[publicKey]], [[privateKey]] and [[invitesKey]] and
|
|
* encrypts the latter two
|
|
*/
|
|
async generateKeys() {
|
|
this.invitesKey = await getProvider().generateKey(new AESKeyParams());
|
|
const { privateKey, publicKey } = await getProvider().generateKey(new RSAKeyParams());
|
|
this.privateKey = privateKey;
|
|
this.publicKey = publicKey;
|
|
await this.setData(new OrgSecrets(this as UnlockedOrg).toBytes());
|
|
}
|
|
|
|
/**
|
|
* Regenerates all cryptographic keys and updates all member signatures
|
|
*/
|
|
async rotateKeys(force = false) {
|
|
if (!force) {
|
|
// Verify members and groups with current public key
|
|
await this.verifyAll();
|
|
}
|
|
|
|
// Rotate Org key pair
|
|
await this.generateKeys();
|
|
|
|
// Rotate org encryption key
|
|
delete this.encryptedData;
|
|
await this.updateAccessors(this.members.filter((m) => m.role === OrgRole.Owner));
|
|
|
|
// Re-sign all members
|
|
await Promise.all(
|
|
this.members.filter((m) => m.role !== OrgRole.Suspended).map((m) => this.addOrUpdateMember(m))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "Unlocks" the organization, granting access to the organizations
|
|
* [[privateKey]] and [[invitesKey]] properties.
|
|
*/
|
|
async unlock(account: UnlockedAccount) {
|
|
await super.unlock(account);
|
|
if (this.encryptedData) {
|
|
const secrets = new OrgSecrets().fromBytes(await this.getData());
|
|
Object.assign(this, secrets);
|
|
}
|
|
}
|
|
|
|
lock() {
|
|
super.lock();
|
|
delete this.privateKey;
|
|
delete this.invitesKey;
|
|
this.invites.forEach((invite) => invite.lock);
|
|
}
|
|
|
|
/**
|
|
* Signs the `member`s public key, id, role and email address so they can be verified later
|
|
*/
|
|
async sign(member: OrgMember): Promise<OrgMember> {
|
|
if (!this.privateKey) {
|
|
throw "Organisation needs to be unlocked first.";
|
|
}
|
|
|
|
member.signature = await getProvider().sign(
|
|
this.privateKey,
|
|
concatBytes(
|
|
[
|
|
stringToBytes(member.id),
|
|
stringToBytes(member.email),
|
|
new Uint8Array([member.role]),
|
|
member.publicKey,
|
|
stringToBytes(member.updated.toISOString()),
|
|
],
|
|
0x00
|
|
),
|
|
this.signingParams
|
|
);
|
|
return member;
|
|
}
|
|
|
|
/**
|
|
* Verifies the `member`s public key, id, role and email address.
|
|
* Throws if verification fails.
|
|
*/
|
|
async verify(member: OrgMember): Promise<void> {
|
|
if (!member.signature) {
|
|
throw new Err(ErrorCode.VERIFICATION_ERROR, "No signed public key provided!");
|
|
}
|
|
|
|
const verified =
|
|
member.updated >= this.minMemberUpdated &&
|
|
(await getProvider().verify(
|
|
this.publicKey,
|
|
member.signature,
|
|
concatBytes(
|
|
[
|
|
stringToBytes(member.id),
|
|
stringToBytes(member.email),
|
|
new Uint8Array([member.role]),
|
|
member.publicKey,
|
|
stringToBytes(member.updated.toISOString()),
|
|
],
|
|
0x00
|
|
),
|
|
this.signingParams
|
|
));
|
|
|
|
if (!verified) {
|
|
throw new Err(ErrorCode.VERIFICATION_ERROR, `Failed to verify public key of ${member.name}!`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify all provided `members`, throws if verification fails for any of them.
|
|
*/
|
|
async verifyAll(members: OrgMember[] = this.members.filter((m) => m.role !== OrgRole.Suspended)) {
|
|
// Verify public keys for members and groups
|
|
await Promise.all(members.map(async (obj) => this.verify(obj)));
|
|
}
|
|
|
|
/**
|
|
* Adds a member to the organization, or updates the existing member with the same id.
|
|
*/
|
|
async addOrUpdateMember({
|
|
id,
|
|
name,
|
|
email,
|
|
publicKey,
|
|
orgSignature,
|
|
role,
|
|
}: {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
publicKey: Uint8Array;
|
|
orgSignature: Uint8Array;
|
|
role?: OrgRole;
|
|
}) {
|
|
if (!this.privateKey) {
|
|
throw "Organisation needs to be unlocked first.";
|
|
}
|
|
|
|
role = typeof role !== "undefined" ? role : OrgRole.Member;
|
|
|
|
const existing = this.members.find((m) => m.id === id);
|
|
const updated = new Date();
|
|
|
|
if (existing) {
|
|
Object.assign(existing, { name, email, publicKey, orgSignature, role, updated });
|
|
await this.sign(existing);
|
|
} else {
|
|
this.members.push(
|
|
await this.sign(new OrgMember({ id, name, email, publicKey, orgSignature, role, updated }))
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a member from the organization
|
|
*/
|
|
async removeMember(member: { id: AccountID }) {
|
|
if (!this.privateKey) {
|
|
throw "Organisation needs to be unlocked first.";
|
|
}
|
|
|
|
// Remove member from all groups
|
|
for (const group of this.getGroupsForMember(member)) {
|
|
group.members = group.members.filter((m) => m.id !== member.id);
|
|
}
|
|
|
|
// Remove member
|
|
this.members = this.members.filter((m) => m.id !== member.id);
|
|
|
|
// Verify remaining members (since we're going to re-sign them)
|
|
await this.verifyAll();
|
|
|
|
// Bump minimum update date
|
|
this.minMemberUpdated = new Date();
|
|
|
|
// Re-sign all members
|
|
await Promise.all(
|
|
this.members.filter((m) => m.role !== OrgRole.Suspended).map((m) => this.addOrUpdateMember(m))
|
|
);
|
|
}
|
|
|
|
toString() {
|
|
return this.name;
|
|
}
|
|
}
|
|
|
|
export interface UnlockedOrg extends Org {
|
|
privateKey: Uint8Array;
|
|
invitesKey: Uint8Array;
|
|
}
|