identity
This is an object representing a user. An Identity object contains a collection of "devices", where each device has several keypairs. This depends on each device having a keystore that stores the private keys.
The keystore
module uses indexedDB to save keys as "non-extractable" keypairs, so we are never able to read the private keys.
We can do e2e encryption by creating a symmetric key, then encrypting that key to each device. So the symmetric key is encrypted with the public key of each device.
Each device has a primary keypair used for signing, which is did
here, and also an "exchange" keypair, which is used for encrypting & decrypting things. In the Device
record there is also an index aes
, which is the symmetrical key that has been encrypted to the device's exchange key.
see also, keystore as used in crypto component
Devices are indexed by a sufficiently random key, created by calling createDeviceName with the primary did for the device.
E2E encryption
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 exchange keys of the other party.
install
npm i -S @bicycle-codes/identity
use
This uses @oddjs/odd to store the local keys.
import { program as createProgram } from '@oddjs/odd'
import { create } from '@bicycle-codes/identity'
const program = await createProgram({
namespace: {
name: 'my-app',
creator: 'my-company'
}
})
crypto = program.components.crypto
identity = await create(crypto, {
humanName: 'alice',
})
demo
See a live demo of the example directory
This uses websockets to 'link' two devices. That is, a single AES key is encrypted to the exchange key on each device, so both devices are able to use the same key.
quick example
Given two identities, create a message that is readble by them only.
import type { EncryptedMessage } from '@bicycle-codes/identity'
import { create, encryptTo, decryptMsg } from '@bicycle-codes/identity'
const alice = await create(crypto, {
humanName: 'alice',
})
const bob = await create(bobsCrypto, {
humanName: 'bob'
})
const encryptedMessage = await encryptTo(
alice,
[bob],
'hello bob'
) as EncryptedMessage
const decryptedMsg = await decryptMsg(bobsCrypto, encryptedMessage)
types
Identity
interface Identity {
humanName:string,
username:string,
rootDid:DID
devices:Record<string, Device>
}
Device
interface Device {
name:string,
did:DID,
aes:string,
exchange:string
}
group
A function from data to an encrypted string.
type Group = {
groupMembers: Identity[];
encryptedKeys: Record<string, string>;
decrypt: (
crypto:Implementation,
group:Group,
msg:string|Uint8Array
) => Promise<string>;
(data:string|Uint8Array): Promise<string>
}
example
Start the example. This will start local servers and open a browser.
npm start
party
The example opens a websocket connection to our partykit server in response to DOM events. We generate a random 6 digit number, and use that to connect multiple devices to the same websocket server. The root device (the one that generated the PIN) will get a message from the new device, containing the exchange public key and DID. The root device then encrypts the AES key to the exchange key in the message, and then sends the encrypted AES key back to the new device over the websocket.
After that both machines have the same AES key, so are able to read & write the same data.
storage
This is storage agnostic. You would want to save the identity object to a database or something, which is easy to do because keys are encrypted "at rest". Any device record pairs with a keystore instance on the device.
env variables
We are not using any env variables. If you use an env variable, deploy to partykit like this:
npx partykit deploy --with-vars
There is an env variable, PARTYKIT_TOKEN
, on github. This is for deploying partykit automatically on any github push. It's not used by our app.
test
Tests run in node because we are using @ssc-hermes/node-components.
npm test
API
Import functions and types
import { test } from '@bicycle-codes/tapzero'
import {
components,
createCryptoComponent
} from '@ssc-hermes/node-components'
import { Crypto } from '@oddjs/odd'
import {
fromString, toString, toString, fromString,
writeKeyToDid, aesEncrypt, aesDecrypt,
create, decryptKey, Identity, ALGORITHM, add,
createDeviceName, encryptTo, CurriedEncrypt,
decryptMsg, DID, sign, signAsString, verifyFromString
} from '@bicycle-codes/identity'
strings
Convenient helpers that will encode and decode strings with base64pad
format.
import { fromString, toString } from '@bicycle-codes/identity'
create
Create an identity
import { program as createProgram } from '@oddjs/odd'
import {
create,
writeKeyToDid,
getDeviceName
} from '@bicycle-codes/identity'
let identity:Identity
let rootDid:DID
let crypto:Crypto.Implementation
let rootDeviceName:string
test('create an identity', async t => {
const program = await createProgram({
namespace: {
name: 'my-app',
creator: 'my-company'
},
debug: true,
fileSystem: {
loadImmediately: false
}
})
crypto = program.components.crypto
rootDid = await writeKeyToDid(crypto)
identity = await create(crypto, {
humanName: 'alice',
})
const deviceName = await getDeviceName(rootDid)
rootDeviceName = deviceName
t.ok(identity, 'should return a new identity')
t.ok(identity.devices[deviceName].aes,
'should index the symmetric key by device name')
})
sign and verify
Sign a given string, and verify the signature.
const { keystore } = crypto
const sig = await sign(keystore, 'hello')
t.ok(sig instanceof Uint8Array, 'should return a Uint8Array')
const sigStr = await signAsString(keystore, 'hello')
const isValid = await verifyFromString(
'hello',
sigStr,
await writeKeyToDid(crypto)
)
t.equal(isValid, true, 'should validate a valid signature')
decryptKey
Decrypt the given encrypted AES key.
async function decryptKey (
crypto:Crypto.Implementation,
encryptedKey:string
):Promise<CryptoKey>
const aes = identity.devices[rootDeviceName].aes
const decryptedKey = await decryptKey(crypto, aes)
Use the decrypted key to read and write
import { aesDecrypt, aesEncrypt } from '@bicycle-codes/identity'
test('can use the keys', async t => {
const aes = identity.devices[rootDeviceName].aes
const decryptedKey = await decryptKey(crypto, aes)
t.ok(decryptedKey instanceof CryptoKey, 'decryptKey should return a CryptoKey')
const encryptedString = await aesEncrypt(
fromString('hello'), decryptedKey, ALGORITHM)
t.ok(encryptedString instanceof Uint8Array,
'should return a Uint8Array when you encrypt a string')
const decrypted = toString(
await aesDecrypt(encryptedString, decryptedKey, ALGORITHM)
)
t.equal(decrypted, 'hello', 'can decrypt the original string')
})
encryptKey
Encrypt a given AES key to a given exchange key. You mostly should not need to use this.
export async function encryptKey (
key:CryptoKey,
exchangeKey:Uint8Array|CryptoKey
):Promise<string>
add
Add a device to this identity.
We need to pass in the crypto
object from the original identity, because we need to decrypt the secret key, then re-encrypt it to the new device:
const secretKey = await decryptKey(
crypto,
id.devices[existingDeviceName].aes
)
We need to call this function from the existing device, because we need to decrypt the AES key. We then re-encrypt the AES key to the public exchange key of the new device. That means we need to get the exchangeKey
of the new device somehow.
test('add a device to the identity', async t => {
const device2Crypto = await createCryptoComponent()
const newDid = await writeKeyToDid(device2Crypto)
const exchangeKey = await device2Crypto.keystore.publicExchangeKey()
const id = await add(identity, crypto, newDid, exchangeKey)
t.ok(id, 'should return a new identity')
const newDeviceName = await createDeviceName(newDid)
t.ok(identity.devices[newDeviceName],
'new identity should have a new device with the expected name')
t.ok(identity.devices[rootDeviceName],
'identity should still have the original device')
})
encryptTo
Encrypt a message to the given set of identities. To decrypt this message, use your exchange key to decrypt the AES key, then use the AES key to decrypt the payload.
export async function encryptTo (
creator:Identity,
ids:Identity[],
data?:string|Uint8Array
):Promise<EncryptedMessage | CurriedEncrypt>
encryptTo
example
const alice = await create(alicesCrypto, {
humanName: 'alice'
})
const bob = await create(bobsCrypto, {
humanName: 'bob'
})
const encryptedMsg = await encryptTo(alice, [bob], 'hello bob')
curried encryptTo
encryptTo
can be partially applied by calling without the last argument, the message.
const encryptedGroup = await encryptTo(alice, [
bob,
carol
]) as CurriedEncrypt
decryptMsg
Decrypt a message. Takes an encrypted message, and returns the decrypted message body.
async function decryptMsg (
crypto:Crypto.Implementation,
encryptedMsg:EncryptedMessage
):Promise<string>
decryptMsg
example
const newMsg = await encryptTo(alice, [bob], 'hello bob') as EncryptedMessage
t.ok(newMsg.payload, 'Encrypted message should have payload')
const newDecryptedMsg = await decryptMsg(bobsCrypto, newMsg)
t.equal(newDecryptedMsg, 'hello bob',
'Bob can decrypt a message encrypted to bob')
group
Create a group of identities that share a single AES key.
This will return a new function that encrypts data with the given key.
This differs from encryptTo
, above, because this takes an existing key, instead of creating a new one.
export type Group = {
groupMembers: Identity[];
encryptedKeys: Record<string, string>;
decrypt: (
crypto:Crypto.Implementation,
group:Group,
msg:string|Uint8Array
) => Promise<string>;
(data:string|Uint8Array): Promise<string>
}
export async function group (
creator:Identity,
ids:Identity[],
key:CryptoKey
):Promise<Group>
group
example
import { group } from '@bicycle-codes/identity'
const myGroup = await group(alice, [bob, carol], key)
group.Decrypt
Decrypt a message that has been encrypted to the group.
async function Decrypt (
group:Group,
crypto:Crypto.Implementation,
msg:string|Uint8Array
):Promise<string>
group.Decrypt
example
import { group } from '@bicycle-codes/identity'
const myGroup = await group(alice, [bob, carol], key)
const groupMsg = await myGroup('hello group')
const msg = await group.Decrypt(alicesCrytpo, myGroup, groupMsg)
AddToGroup
Add another identity to a group, and return a new group (not the same instance).
If you pass in a Crypto.Implementation
instance, then we will use that to decrypt the key of the given group.
If you pass in an AES CryptoKey
, it will be encrypted to the new user. It should be the same AES key that is used by the group.
async function AddToGroup (
group:Group,
keyOrCrypto:CryptoKey|Implementation,
newGroupMember:Identity,
):Promise<Group>
AddToGroup
example
import { AddToGroup, create } from '@bicycle-codes/identity'
const fran = await create(_crypto, {
humanName: 'fran'
})
const newGroup = await AddToGroup(myGroup, alicesCrytpo, fran)
getDeviceName
Create a URL-friendly hash string for a device. This is 32 characters of a hash
for a given device's DID. It will always return the same string for the
same DID/device.
Pass in a crypto
instance or DID string
async function getDeviceName (input:DID|Crypto.Implementation):Promise<string>
getDeviceName
example
Pass in a crypto
instance
import { getDeviceName } from '@bicycle-codes/identity'
const myDeviceName = getDeviceName(program.components.crypto)
Pass in a DID as a string
import { getDeviceName } from '@bicycle-codes/identity'
const deviceName = getDeviceName('did:key:z13V3Sog2Y...')