Skip to content

Commit

Permalink
store JWK Set in backend
Browse files Browse the repository at this point in the history
  • Loading branch information
overheadhunter committed May 10, 2024
1 parent b46f55e commit b0790aa
Show file tree
Hide file tree
Showing 10 changed files with 124 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNu
vault.setDescription(vaultDto.description);
vault.setArchived(existingVault.isPresent() && vaultDto.archived);
vault.setUvfMetadataFile(vaultDto.uvfMetadataFile);
vault.setUvfRecoveryPubKey(vaultDto.uvfRecoveryPublicKey);
vault.setUvfKeySet(vaultDto.uvfKeySet);

vaultRepo.persistAndFlush(vault); // trigger PersistenceException before we continue with
if (existingVault.isEmpty()) {
Expand Down Expand Up @@ -502,15 +502,15 @@ public record VaultDto(@JsonProperty("id") UUID id,
@JsonProperty("archived") boolean archived,
@JsonProperty("creationTime") Instant creationTime,
@JsonProperty("uvfMetadataFile") String uvfMetadataFile,
@JsonProperty("uvfRecoveryPublicKey") @OnlyBase64Chars String uvfRecoveryPublicKey,
@JsonProperty("uvfKeySet") String uvfKeySet,
// Legacy properties ("Vault Admin Password"):
@JsonProperty("masterkey") @OnlyBase64Chars String masterkey, @JsonProperty("iterations") Integer iterations, @JsonProperty("salt") @OnlyBase64Chars String salt,
@JsonProperty("authPublicKey") @OnlyBase64Chars String authPublicKey, @JsonProperty("authPrivateKey") @OnlyBase64Chars String authPrivateKey

) {

public static VaultDto fromEntity(Vault entity) {
return new VaultDto(entity.getId(), entity.getName(), entity.getDescription(), entity.isArchived(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS), entity.getUvfMetadataFile(), entity.getUvfRecoveryPubKey(),
return new VaultDto(entity.getId(), entity.getName(), entity.getDescription(), entity.isArchived(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS), entity.getUvfMetadataFile(), entity.getUvfKeySet(),
// legacy properties:
entity.getMasterkey(), entity.getIterations(), entity.getSalt(), entity.getAuthenticationPublicKey(), entity.getAuthenticationPrivateKey());
}
Expand Down
18 changes: 9 additions & 9 deletions backend/src/main/java/org/cryptomator/hub/entities/Vault.java
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ public class Vault {
@Column(name = "uvf_metadata_file")
private String uvfMetadataFile;

@Column(name = "uvf_recovery_pubkey")
private String uvfRecoveryPubKey;
@Column(name = "uvf_jwks")
private String uvfKeySet;

public Optional<ECPublicKey> getAuthenticationPublicKeyOptional() {
if (authenticationPublicKey == null) {
Expand Down Expand Up @@ -244,12 +244,12 @@ public void setUvfMetadataFile(String uvfMetadataFile) {
this.uvfMetadataFile = uvfMetadataFile;
}

public String getUvfRecoveryPubKey() {
return uvfRecoveryPubKey;
public String getUvfKeySet() {
return uvfKeySet;
}

public void setUvfRecoveryPubKey(String uvfRecoveryPubKey) {
this.uvfRecoveryPubKey = uvfRecoveryPubKey;
public void setUvfKeySet(String uvfKeySet) {
this.uvfKeySet = uvfKeySet;
}

@Override
Expand All @@ -264,12 +264,12 @@ public boolean equals(Object o) {
&& Objects.equals(masterkey, vault.masterkey)
&& Objects.equals(archived, vault.archived)
&& Objects.equals(uvfMetadataFile, vault.uvfMetadataFile)
&& Objects.equals(uvfRecoveryPubKey, vault.uvfRecoveryPubKey);
&& Objects.equals(uvfKeySet, vault.uvfKeySet);
}

@Override
public int hashCode() {
return Objects.hash(id, name, salt, iterations, masterkey, archived, uvfMetadataFile, uvfRecoveryPubKey);
return Objects.hash(id, name, salt, iterations, masterkey, archived, uvfMetadataFile, uvfKeySet);
}

@Override
Expand All @@ -284,7 +284,7 @@ public String toString() {
", masterkey='" + masterkey + '\'' +
", archived='" + archived + '\'' +
", uvfMetadataFile='" + uvfMetadataFile + '\'' +
", uvfRecoveryPubKey='" + uvfRecoveryPubKey + '\'' +
", uvfKeySet='" + uvfKeySet + '\'' +
'}';
}

Expand Down
Binary file modified backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
ALTER TABLE vault ADD uvf_metadata_file VARCHAR UNIQUE; -- vault.uvf file, encrypted as JWE
ALTER TABLE vault ADD uvf_recovery_pubkey VARCHAR UNIQUE;
ALTER TABLE vault ADD uvf_jwks VARCHAR UNIQUE; -- encoded as JWKs
2 changes: 1 addition & 1 deletion frontend/src/common/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type VaultDto = {
authPublicKey?: string;
authPrivateKey?: string;
uvfMetadataFile?: string;
uvfRecoveryPublicKey?: string;
uvfKeySet?: string;
};

export type DeviceDto = {
Expand Down
35 changes: 28 additions & 7 deletions frontend/src/common/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { VaultDto } from './backend';
import { JWE, Recipient } from './jwe';
import { DB } from './util';

/**
* Represents a JSON Web Key (JWK) as defined in RFC 7517.
* @see https://datatracker.ietf.org/doc/html/rfc7517#section-5
*/
export type JsonWebKeySet = {
keys: JsonWebKey & { kid?: string }[] // RFC defines kid, but webcrypto spec does not
}

export class UnwrapKeyError extends Error {
readonly actualError: any;
Expand Down Expand Up @@ -290,16 +297,30 @@ export async function getFingerprint(key: string | undefined) {

/**
* Computes the JWK Thumbprint (RFC 7638) using SHA-256.
* @param key An EC key
* @param key A key to compute the thumbprint for
* @throws Error if the key is not supported
*/
export async function getJwkThumbprint(key: CryptoKey): Promise<string> {
export async function getJwkThumbprint(key: JsonWebKey | CryptoKey): Promise<string> {
let jwk: JsonWebKey;
if (key instanceof CryptoKey) {
jwk = await crypto.subtle.exportKey('jwk', key);
} else {
jwk = key;
}
// see https://datatracker.ietf.org/doc/html/rfc7638#section-3.2
if (key.algorithm.name !== 'ECDH') {
throw new Error('Method only implemented for EC keys.');
let orderedJson: string;
switch (jwk.kty) {
case 'EC':
orderedJson = `{"crv":"${jwk.crv}","kty":"${jwk.kty}","x":"${jwk.x}","y":"${jwk.y}"}`;
break;
case 'RSA':
orderedJson = `{"e":"${jwk.e}","kty":"${jwk.kty}","n":"${jwk.n}"}`;
break;
case 'oct':
orderedJson = `{"k":"${jwk.k}","kty":"${jwk.kty}"}`;
break;
default: throw new Error('Unsupported key type');
}
const jwk = await crypto.subtle.exportKey('jwk', key);
const algo = key.algorithm as EcKeyAlgorithm;
const orderedJson = `{"crv":"${algo.namedCurve}","kty":"${jwk.kty}","x":${jwk.x},"y":${jwk.y}}`;
const bytes = new TextEncoder().encode(orderedJson);
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
return base64url.stringify(new Uint8Array(hashBuffer), { pad: false });
Expand Down
38 changes: 30 additions & 8 deletions frontend/src/common/universalVaultFormat.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import JSZip from 'jszip';
import { base64, base64url } from 'rfc4648';
import { VaultDto } from './backend';
import { AccessTokenPayload, AccessTokenProducing, OtherVaultMember, UserKeys, VaultTemplateProducing, getJwkThumbprint } from './crypto';
import { AccessTokenPayload, AccessTokenProducing, JsonWebKeySet, OtherVaultMember, UserKeys, VaultTemplateProducing, getJwkThumbprint } from './crypto';
import { JWE, JWEHeader, JsonJWE, Recipient } from './jwe';
import { CRC32, wordEncoder } from './util';

Expand Down Expand Up @@ -183,11 +183,18 @@ export class RecoveryKey {

/**
* Encodes the public key
* @returns public key in base64-encoded DER format
* @returns public key in JWK format
*/
public async serializePublicKey(): Promise<string> {
const bytes = await crypto.subtle.exportKey('spki', this.publicKey);
return base64.stringify(new Uint8Array(bytes), { pad: true });
const jwk = await crypto.subtle.exportKey('jwk', this.publicKey);
const thumbprint = await getJwkThumbprint(jwk);
return JSON.stringify({
kid: `org.cryptomator.hub.recoverykey.${thumbprint}`,
kty: jwk.kty,
crv: jwk.crv,
x: jwk.x,
y: jwk.y
});
}

}
Expand Down Expand Up @@ -261,7 +268,8 @@ export class VaultMetadata {
public async encrypt(memberKey: MemberKey, recoveryKey: RecoveryKey): Promise<string> {
const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprint(recoveryKey.publicKey)}`;
const protectedHeader: JWEHeader = {
jku: 'jku.jwks' // URL relative to /api/vaults/{vaultid}/
origin: `https://example.com/api/vaults/TODO/uvf/vault.uvf`, // TODO use ${absBackendBaseURL}. Couldn't do this, because tests fail for mysterious reasons
jku: 'jwks.json' // URL relative to origin
};
const jwe = await JWE.build(this.payload(), protectedHeader).encrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey.key), Recipient.ecdhEs(recoveryKeyID, recoveryKey.publicKey));
const json = jwe.jsonSerialization();
Expand Down Expand Up @@ -304,21 +312,35 @@ export class UniversalVaultFormat implements AccessTokenProducing, VaultTemplate
}

public static async decrypt(vault: VaultDto, accessToken: string, userKeyPair: UserKeys): Promise<UniversalVaultFormat> {
if (!vault.uvfMetadataFile || !vault.uvfRecoveryPublicKey) {
if (!vault.uvfMetadataFile || !vault.uvfKeySet) {
throw new Error('Not a UVF vault.');
}
const jwks = JSON.parse(vault.uvfKeySet) as JsonWebKeySet;
const recoveryPublicKey = await this.getRecoveryPublicKeyFromJwks(jwks);
const payload = await userKeyPair.decryptAccessToken(accessToken) as UvfAccessTokenPayload;
const memberKey = await MemberKey.load(payload.key);
const metadata = await VaultMetadata.decryptWithMemberKey(vault.uvfMetadataFile, memberKey);
let recoveryKey: RecoveryKey;
if (payload.recoveryKey) {
recoveryKey = await RecoveryKey.import(base64.parse(vault.uvfRecoveryPublicKey), base64.parse(payload.recoveryKey));
recoveryKey = await RecoveryKey.import(recoveryPublicKey, base64.parse(payload.recoveryKey));
} else {
recoveryKey = await RecoveryKey.import(base64.parse(vault.uvfRecoveryPublicKey));
recoveryKey = await RecoveryKey.import(recoveryPublicKey);
}
return new UniversalVaultFormat(metadata, memberKey, recoveryKey);
}

private static async getRecoveryPublicKeyFromJwks(jwks: JsonWebKeySet): Promise<CryptoKey> {
for (const key of jwks.keys) {
if (key.kid?.startsWith('org.cryptomator.hub.recoverykey.')) {
const thumbprint = await getJwkThumbprint(key as JsonWebKey);
if (key.kid === `org.cryptomator.hub.recoverykey.${thumbprint}`) {
return await crypto.subtle.importKey('jwk', key as JsonWebKey, RecoveryKey.KEY_DESIGNATION, true, []);
}
}
}
throw new Error('Recovery key not found in JWKS');
}

public async createMetadataFile(): Promise<string> {
return this.metadata.encrypt(this.memberKey, this.recoveryKey);
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/CreateVault.vue
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,9 @@ async function createVault() {
throw new Error('Invalid state');
}
ownerGrant.token = await uvfVault.value.encryptForUser(base64.parse(owner.publicKey), true);
const recoveryPublicKey = await uvfVault.value.recoveryKey.serializePublicKey();
vault.value.uvfMetadataFile = await uvfVault.value.createMetadataFile();
vault.value.uvfRecoveryPublicKey = await uvfVault.value.recoveryKey.serializePublicKey();
vault.value.uvfKeySet = `{"keys": [${recoveryPublicKey}]}`;
break;
}
}
Expand Down
21 changes: 20 additions & 1 deletion frontend/test/common/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { use as chaiUse, expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { before, describe } from 'mocha';
import { base64 } from 'rfc4648';
import { UnwrapKeyError, UserKeys } from '../../src/common/crypto';
import { UnwrapKeyError, UserKeys, getJwkThumbprint } from '../../src/common/crypto';

chaiUse(chaiAsPromised);

Expand Down Expand Up @@ -153,6 +153,25 @@ describe('crypto', () => {
expect(encoded).to.eq('MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEem7I0xHVyliLrtQb4+mPMMkpSETsu2KZlWU2NdvCLaLwg/KXEeD5xZY7wCG9jLIQna9WpV+IOnIAzqnE3kRIjm3En7nDlPUctaSfxp1+igNHkpY65Oq8Y0g6LPGomejI');
});
});

describe('JWK Thumbprint', () => {

// https://datatracker.ietf.org/doc/html/rfc7638#section-3.1
it('compute example thumbprint from RFC 7638, Section 3.1', async () => {
const input: JsonWebKey & any = {
kty: 'RSA',
n: '0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw',
e: 'AQAB',
alg: 'RS256',
kid: '2011-04-29'
};

const thumbprint = await getJwkThumbprint(input);

expect(thumbprint).to.eq('NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs');
});

});
});

/* ---------- MOCKS ---------- */
Expand Down
Loading

0 comments on commit b0790aa

Please sign in to comment.