Use non-extractable keypairs as user identity.
Use the webcrypto API to create keypairs representing a user.
All encryption is via AES-GCM.
All asymmetric crypto is using RSA, because we are waiting for more browsers to support ECC.
Create two keypairs -- 1 for signing and 1 for encrypting, and store them in indexedDB
in the browser. All keypairs here are "non-extractable", so you are never able to read the private key, but they still persist indefinitely in indexedDB.
We can do passwordless user ID, using something like UCAN to link multiple devices if you want.
We can do e2e encryption by creating a symmetric key, then encrypting that key to each device that should be able to read the message. So the symmetric key is encrypted with the public key of each device.
Devices are indexed by a sufficiently random key, created by calling getDeviceName with the primary did for the device.
Sending a private message to an identity would mean encrypting a message with a new symmetric key, then encrypting n
versions of the symmetric key, one for each device in the other identity.
You can think of it like one conversation = 1 symmetric key. The person initiating the conversation needs to know the encryption keys of the other party.
npm i -S @bicycle-codes/identity
import { Identity } from '@bicycle-codes/identity'
const id = await Identity.create({
humanName: 'alice',
humanReadableDeviceName: 'phone'
})
Given two identities, create a message that is readble by them only.
import type { EncryptedMessage } from '@bicycle-codes/identity'
import {
encryptContent,
Identity
} from '@bicycle-codes/identity'
// get identities somehow
const alice = await Identity.create({
humanName: 'alice',
humanReadableDeviceName: 'phone'
})
const bob = await Identity.create({
humanName: 'bob',
humanReadableDeviceName: 'computer'
})
const msgToBob = await alice.encryptMsg('hello bob', [
await bob.serialize() // <-- pass in recipients
])
const decrypted = await bob.decryptMsg(msgToBob)
// => 'hello bob'
// __the encrypted message__
//
// => {
// payload:string, /* This is the message, encrypted with the AES key for
// this message */
// devices:Record<string, string> <-- A map from device name to AES key,
// encrypted to the device
// }
//
const alice = await Identity.create({
humanName: 'alice',
humanReadableDeviceName: 'phone'
})
Create an AES key and encrypt it to an RSA keypair.
import {
aesGenKey,
aesExportKey,
encryptKey,
} from '@bicycle-codes/identity'
import { AES_GCM } from '@bicycle-codes/identity/CONSTANTS'
const alice = await Identity.create({
humanName: 'alice',
humanReadableDeviceName: 'phone'
})
const key = await aesGenKey({ alg: AES_GCM, length: 256 })
const exported = await aesExportKey(key)
const encryptedKey = await encryptKey(key, alice.encryptionKey.publicKey)
import { decryptKey } from '@bicycle-codes/identity'
test('decrypt key', async t => {
const decryptedKey = await decryptKey(encryptedKey, alice.encryptionKey)
t.ok(decryptedKey instanceof CryptoKey, 'should return a key')
const exported = await aesExportKey(decryptedKey)
t.equal(uArrs.toString(exported, 'base64pad'), plaintextKey,
'should decrypt to the the same key')
})
Create a message that only Alice can decrypt.
test('encrypt a message', async t => {
msg = await alice.encryptMsg('hello world')
t.equal(typeof msg.payload, 'string', 'should create a message object')
t.ok(msg.devices[alice.rootDeviceName],
'should encrypt the message to its author')
})
const msgToBob = await alice.encryptMsg('hello bob', [
await bob.serialize() // <-- pass in recipients
])
test('decrypt a message from another person', async t => {
const decrypted = await bob.decryptMsg(msgToBob)
t.equal(decrypted, 'hello bob', 'should decrypt the message')
})
See bicycle-codes.github.io/identity for complete API docs.
We use some "global" keys in indexedDB
and localStorage
. These can be configured by setting class properties.
encryption-key
-- RSA key for encrypt/decryptsigning-key
-- RSA key for signatures
Set the class properties ENCRYPTION_KEY_NAME
and SIGNING_KEY_NAME
to configure this.
class Identity {
static ENCRYPTION_KEY_NAME:string = 'encryption-key'
static SIGNING_KEY_NAME:string = 'signing-key'
}
identity
-- store a serialized Identity here, when you call save.
Configure this with the class property STORAGE_KEY
.
class Identity {
static STORAGE_KEY:string = 'identity'
}
Import functions and types
import {
type EncryptedMessage,
Identity,
encryptKey,
aesExportKey,
decryptKey,
aesGenKey,
aesDecrypt,
encryptContent,
exportPublicKey,
verifyFromString,
getDeviceName
} from '@bicycle-codes/identity'
Use this factory function, not the constructor, because it is async.
By default this will store keypairs in indexedDB
with the keys encryption-key
and signing-key
. Set the class properties ENCRYPTION_KEY_NAME
and SIGNING_KEY_NAME
to change these.
class Identity {
static async create (opts:{
humanName:string;
type?: 'rsa';
humanReadableDeviceName:string; // a name for this device
}):Promise<Identity>
}
const alice = await Identity.create({
humanName: 'alice',
humanReadableDeviceName: 'phone'
})
Save an existing Identity to localStorage
.
By default this saves to the localStorage
key identity
. Set the class property STORAGE_KEY
to change the storage key.
class Identity {
static STORAGE_KEY:string = 'identity'
static save (id:SerializedIdentity) {
localStorage.setItem(Identity.STORAGE_KEY, JSON.stringify(id))
}
}
import { Identity } from '@bicycle-codes/identity'
// `alice` is an id we created earlier
Identity.save(await alice.serialize())
Load an Identity that has been saved in localStorage
& indexedDB
.
class Identity {
static async init (opts:{
type?:'rsa';
encryptionKeyName:string;
signingKeyName:string;
} = {
encryptionKeyName: DEFAULT_ENCRYPTION_KEY_NAME,
signingKeyName: DEFAULT_SIGNING_KEY_NAME
}):Promise<Identity>
}
import { Identity } from '@bicycle-codes/identity'
const alice = await Identity.init()
Return a JSON stringifiable version of this Identity.
class Identity {
async serialize ():Promise<SerializedIdentity>
}
Create a 32-character, DNS-friendly hash for a device. Takes either the DID string or a CryptoKeyPair.
/**
* Get the device name -- a 32 character, DNS-friendly name
*
* @returns {Promise<string>}
*/
class Identity {
getDeviceName ():Promise<string>
}
const alice = Identity.create({
humanName: 'alice',
humanReadableDeviceName: 'phone'
})
alice.getDeviceName()
// => qfcip23vxaiprwmbyo3dxyrurltt4rgo
Sign the given message with the RSA signingKey
; return a Uint8Array
.
class Identity {
sign (msg:Msg, charsize?:CharSize):Promise<Uint8Array>
}
Sign the given message with the RSA signingKey
; return a string
.
class Identity {
signAsString (msg:string):Promise<string>
}
Create a new Device record. This means creating asymmetric keypairs for the device, and storing them in indexedDB
. This does not include an AES key in the device record, because typically you create a device record before adding the device to a different Identity, so you would add an AES key at that point.
This function does read the class properties ENCRYPTION_KEY_NAME
and SIGNING_KEY_NAME
, because it creates asymmetric keys for the device and saves them in localStorage
.
class Identity {
static async createDeviceRecord (opts:{
humanReadableName:string
}):Promise<Omit<Device, 'aes'>> {
}
Each new message gets a new AES key. The key is then encrypted to the public key of each recipient.
class Identity {
async encryptMsg (
data:string|Uint8Array,
recipients?:SerializedIdentity[],
):Promise<EncryptedMessage>
}
The given message should include an AES key, encrypted to this device. Look up the AES key by device name, and use it to decrypt the message.
class Identity {
async decryptMsg (encryptedMsg:EncryptedMessage):Promise<string>
}
Add a new device to this Identity. Returns this
.
class Identity {
async addDevice (opts:Omit<Device, 'aes'>):Promise<Identity>
}
import { Identity } from '@bicycle-codes/identity'
const alice = Identity.create({ /* ... */ })
// ... need to get the other device record somehow ...
const workComputer:Device = // ...
await alice.addDevice(workComputer)
interface Device {
name:string; // <-- random, collision resistant name
humanReadableName:string;
did:DID;
aes:string; /* <-- the symmetric key for this account, encrypted to the
exchange key for this device */
encryptionKey:string; // <-- encryption key, stringified
}
interface SerializedIdentity {
humanName:string;
username:string;
DID:DID;
rootDID:DID;
rootDeviceName:string;
devices:Record<string, Device>;
storage:{ encryptionKeyName:string; signingKeyName:string; }
}
type Msg = ArrayBuffer|string|Uint8Array
Each new message gets a new AES key. The key is then encrypted to the public key of each recipient.
interface EncryptedMessage<T extends string = string> {
payload:T, /* This is the message, encrypted with the symm key for
the message */
devices:Record<string, string> /* a map from `deviceName` to this
messages's encrypted AES key, encrypted to that device */
}
This package exposes some type checking utilities made with zod. Import z
:
import { device, SerializedIdentity, did } from '@bicycle-codes/identity/z'
Tests run in a browser environment via tape-run.
npm test