Skip to content

bicycle-codes/identity

Repository files navigation

identity

tests module types semantic versioning install size license

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.


contents


conceptual view

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.

E2E encryption

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.


install


npm i -S @bicycle-codes/identity

use

import { Identity } from '@bicycle-codes/identity'

const id = await Identity.create({
    humanName: 'alice',
    humanReadableDeviceName: 'phone'
})

quick example


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
// }
//

Examples


Create a new Identity

const alice = await Identity.create({
    humanName: 'alice',
    humanReadableDeviceName: 'phone'
})

Create a new AES key and encrypt it

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)

Decrypt a key

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')
})

Encrypt a message

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')
})

Encrypt a message to another Identity

const msgToBob = await alice.encryptMsg('hello bob', [
    await bob.serialize()  // <-- pass in recipients
])

Decrypt a message from someone else

test('decrypt a message from another person', async t => {
    const decrypted = await bob.decryptMsg(msgToBob)
    t.equal(decrypted, 'hello bob', 'should decrypt the message')
})

API


See bicycle-codes.github.io/identity for complete API docs.

globals

We use some "global" keys in indexedDB and localStorage. These can be configured by setting class properties.

indexedDB

  • encryption-key -- RSA key for encrypt/decrypt
  • signing-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'
}

localStorage

  • 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


Import functions and types

import {
    type EncryptedMessage,
    Identity,
    encryptKey,
    aesExportKey,
    decryptKey,
    aesGenKey,
    aesDecrypt,
    encryptContent,
    exportPublicKey,
    verifyFromString,
    getDeviceName
} from '@bicycle-codes/identity'

create

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>
}

example

const alice = await Identity.create({
    humanName: 'alice',
    humanReadableDeviceName: 'phone'
})

save

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))
    }
}

example

import { Identity } from '@bicycle-codes/identity'

// `alice` is an id we created earlier
Identity.save(await alice.serialize())

init

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>
}

example

import { Identity } from '@bicycle-codes/identity'

const alice = await Identity.init()

serialize

Return a JSON stringifiable version of this Identity.

class Identity {
    async serialize ():Promise<SerializedIdentity>
}

getDeviceName

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>
}

example

const alice = Identity.create({
    humanName: 'alice',
    humanReadableDeviceName: 'phone'
})

alice.getDeviceName()
// => qfcip23vxaiprwmbyo3dxyrurltt4rgo

sign

Sign the given message with the RSA signingKey; return a Uint8Array.

class Identity {
    sign (msg:Msg, charsize?:CharSize):Promise<Uint8Array>
}

signAsString

Sign the given message with the RSA signingKey; return a string.

class Identity {
    signAsString (msg:string):Promise<string>
}

static createDeviceRecord

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'>> {
}

encryptMsg

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>
}

decryptMsg

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>
}

addDevice

Add a new device to this Identity. Returns this.

class Identity {
    async addDevice (opts:Omit<Device, 'aes'>):Promise<Identity>
}

example

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)

types


Device

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
}

SerializedIdentity

interface SerializedIdentity {
    humanName:string;
    username:string;
    DID:DID;
    rootDID:DID;
    rootDeviceName:string;
    devices:Record<string, Device>;
    storage:{ encryptionKeyName:string; signingKeyName:string; }
}

Msg

type Msg = ArrayBuffer|string|Uint8Array

EncryptedMessage

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 */
}

z


This package exposes some type checking utilities made with zod. Import z:

import { device, SerializedIdentity, did  } from '@bicycle-codes/identity/z'

test


Tests run in a browser environment via tape-run.

npm test